Compare commits

..

3 commits

126 changed files with 2419 additions and 8504 deletions

View file

@ -1,16 +0,0 @@
---
name: REUSE Compliance Check
on:
push:
branches:
- "main"
jobs:
check:
runs-on: docker
steps:
- name: Checkout Repository
uses: https://code.forgejo.org/actions/checkout@v4
- name: REUSE Compliance Check
uses: https://github.com/fsfe/reuse-action@v4

View file

@ -1,22 +0,0 @@
---
name: Tests
on:
pull_request:
types:
- opened
- synchronize
jobs:
test:
if: ${{ ! github.event.pull_request.draft }}
runs-on: docker
steps:
- name: Checkout Repository
uses: https://code.forgejo.org/actions/checkout@v4
- name: Test
uses: https://codeflow.dananglin.me.uk/actions/mage-ci@main
with:
target: test
env:
ENBAS_TEST_COVER: "1"

View file

@ -10,7 +10,7 @@ run:
tests: true tests: true
output: output:
formats: colored-line-number format: colored-line-number
print-issues-lines: true print-issues-lines: true
print-linter-name: true print-linter-name: true
uniq-by-line: true uniq-by-line: true
@ -31,9 +31,5 @@ linters-settings:
linters: linters:
enable-all: true enable-all: true
disable: disable:
- execinquery #- json
- exhaustruct
- gomnd
- mnd
- tagliatelle
fast: false fast: false

10
.reuse/dep5 Normal file
View file

@ -0,0 +1,10 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: enbas
Upstream-Contact: Dan Anglin <d.n.i.anglin@gmail.com>
Source: https://codeflow.dananglin.me.uk/apollo/enbas
# Sample paragraph, commented out:
#
# Files: src/*
# Copyright: $YEAR $NAME <$CONTACT>
# License: ...

256
README.asciidoc Normal file
View file

@ -0,0 +1,256 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: CC-BY-4.0
= Enbas
:toc: left
:toclevels: 3
:toc-title: Table of Contents
== Overview
Enbas is a https://docs.gotosocial.org/en/latest/[GoToSocial] client for your terminal written
in https://go.dev[Go]. The project is in its experimental stages of development so bugs and breaking
changes may appear. Enbas has limited functionality at the moment and it is **not** recommended for use
with your production GoToSocial servers.
This project is licensed under the GNU General Public License V3 or later which you can view link:LICENSES/GPL-3.0-or-later.txt[here].
=== Repository mirrors
- **Code Flow:** https://codeflow.dananglin.me.uk/apollo/enbas
- **Codeberg:** https://codeberg.org/dananglin/enbas
- **GitHub:** https://github.com/dananglin/enbas
== Installation
=== Download
Pre-built binaries will be available on the release page on both Codeberg and GitHub when the first
release is made.
=== Build from source
==== Requirements
===== Go
A minimum version of Go 1.22.0 is required for installing spruce.
Please go https://go.dev/dl/[here] to download the latest version.
===== Mage (Optional)
The project includes mage targets for building and installing the binary.
The main advantage of using mage over `go install` is that the build information is baked into the binary
during compilation. You can visit the https://magefile.org/[Mage website] for instructions on how to install Mage.
==== Install with mage
You can install Enbas with Mage using the following commands:
[source,console]
----
git clone https://github.com/dananglin/enbas.git
mage install
----
By default Mage will attempt to install Enbas to `/usr/local/bin/enbas` which will most likely fail as you won't
the permission to write to `/usr/local/bin/`. You will need to either run `sudo mage install`, or you can
(preferably) change the install prefix to a directory that you have permission to write to using
the `ENBAS_INSTALL_PREFIX` environment variable. For example:
[source,console]
----
ENBAS_INSTALL_PREFIX=${HOME}/.local mage install
----
This will install Enbas to `~/.local/bin/enbas`.
===== Environment variables you can use with Mage
[%header,cols=2*]
|===
|Environment Variable
|Description
|`ENBAS_INSTALL_PREFIX`
|Set this to your preferred the installation prefix (default: `/usr/local`).
|`ENBAS_BUILD_REBUILD_ALL`
|Set this to `1` to rebuild all packages even if they are already up-to-date.
|`ENBAS_BUILD_VERBOSE`
|Set this to `1` to enable verbose logging when building the binary.
|===
==== Install with go
If your `GOBIN` directory is included in your `PATH` then you can install Enbas with Go.
[source,console]
----
git clone https://github.com/dananglin/enbas.git
cd enbas
go install ./cmd/enbas
----
== Configuration
Enbas uses Go's https://pkg.go.dev/os#UserConfigDir[os.UserConfigDir()] function to determine the
location of your configuration directory.
If you've set the `XDG_CONFIG_HOME` environment variable, the configuration files will be stored in the `$XDG_CONFIG_HOME/enbas` directory.
If this is not set:
- on Linux the configuration directory will be set to `$HOME/.config/enbas`.
- on Darwin (MacOS) the configuration directory will be set to `$HOME/Library/Application Support/enbas`.
- on Windows the configuration directory will be set within the `%AppData%` directory.
If, for whatever reason, any of the above cannot be determined the configuration directory will be set to
the current working directory.
== Usage
=== Check the build information
You can view the application's version and build information using the `--version` flag.
The build information is correctly displayed if you've downloaded the binary from Codeberg or GitHub,
or if you've built it with Mage.
[source,console]
----
$ enbas version --full
Enbas
Version: v0.0.0-13-g26a909d
Git commit: 26a909d
Go version: go1.22.0
Build date: 2024-02-25T15:22:55Z
----
=== Check out the help documentation
You can view the help documentation with the `--help` flag.
You can also use this flag to view the help documentation for any of the commands.
[source,console]
----
$ enbas --help
SUMMARY:
enbas - A GoToSocial client for the terminal.
VERSION:
v0.0.0-13-g26a909d
USAGE:
enbas [flags]
enbas [command]
COMMANDS:
login login to an account on GoToSocial
show print details about a specified resource
switch switch to an account
version print the application's version and build information
FLAGS:
--help
print the help message
Use "enbas [command] --help" for more information about a command.
----
=== Log into your GoToSocial account
Enbas uses the Oauth2 authentication flow to log into your account on GoToSocial.
This process requires your input to give consent to allow Enbas access to your account.
[WARNING]
====
As of writing GoToSocial does not currently support scoped authorization tokens so even if we request read-only
tokens, the application will be able to perform any actions within the limitations of your account
(including admin actions if you are an admin).
You can read more about this https://docs.gotosocial.org/en/latest/api/authentication/[here].
====
The login flow is completed using the following steps:
1. You start by using the `login` command specifying the instance that you want to log into.
+
[source,console]
----
enbas login --instance gotosocial-01.social.example
----
2. The application will register itself and the GoToSocial server will create a new client ID and secret that the app needs for authentication.
3. The application will then generate a link to the consent form for you to access in your browser.
This link will be printed on your terminal screen along with a message explaining that you need to obtain the `out-of-band` token to continue.
If you have the `BROWSER` environment variable set or if you're using Linux, the link will open in a new browser tab for you to sign into your account.
If the browser tab doesn't open, you can manually copy and paste the link in your favourite browser.
4. Once you've signed into GoToSocial on your browser, you will be informed that Enbas would like to perform actions on your behalf.
If you're happy with this then click on the `Allow` button.
+
image::assets/images/consent_form.png[A screenshot of the consent form]
5. The `out-of-band` token will be printed for you at this point. Copy it and return to your terminal.
6. Paste the token into the prompt and press `ENTER`.
Enbas will then exchange the token for an access token which will be used to authentication to the
GoToSocial server on your behalf.
Enbas will then verify the access token, save the credentials to the `credentials.json` file in your configuration directory,
and confirm that you have successfully logged into your account.
+
[source,console]
----
$ enbas login --instance gotosocial-01.social.example
You'll need to sign into your GoToSocial's consent page in order to generate the out-of-band token to continue with
the application's login process. Your browser may have opened the link to the consent page already. If not, please
copy and paste the link below to your browser:
https://gotosocial-01.social.example/oauth/authorize?client_id=01RHK48N1KH9SFNH2VVZR414BJ&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob&response_type=code
Once you have the code please copy and paste it below.
Out-of-band token: ZGJKNDA2YWMTNGEYMS0ZZJLJLWJHNDITM2IZYJJLNJM3YJBK
Successfully logged into bobby@gotosocial-01.social.example
----
=== Common actions
* View your account information
+
[source,console]
----
enbas show --type account --my-account
----
* View a local or remote account
+
[source,console]
----
enbas show --type account --account teddy@gotosocial-01.social.example
----
* View your home timeline
+
[source,console]
----
enbas show --type timeline
----
* View the details of a status
+
[source,console]
----
enbas show --type status --status-id 01HQE43KT5YEDN4RGMT7BC63PF
----
== Inspirations
This project was inspired from the following projects:
* **madonctl:** https://github.com/McKael/madonctl[A Mastodon CLI client written in Go.]
* **toot:** https://pypi.org/project/toot/[A Mastodon CLI and TUI written in Python.]
* **tut:** https://github.com/RasmusLindroth/tut[A Mastodon TUI written in Go.]

View file

@ -1,43 +0,0 @@
# Enbas
### Overview
Enbas is a [GoToSocial](https://docs.gotosocial.org/en/latest/) client for your terminal written
in [Go](https://go.dev).
The project is in its early stages of development so expect bugs, breaking changes and limited
functionality.
At this time it is not recommended for use this with your production GoToSocial instances.
### Repository mirrors
Enbas is actively developed in [Code Flow](https://codeflow.dananglin.me.uk/apollo/enbas) with
the `main` branch mirrored to the following forges:
- **Codeberg**: https://codeberg.org/dananglin/enbas
- **Radicle**: https://app.radicle.xyz/nodes/seed.radicle.garden/rad:zhqv2orTvTh2x2d7kYky9NhctrpK
- **GitHub**: https://github.com/dananglin/enbas
### Documentation
- **[Installation guide](docs/installation.md)**: A guide for installing Enbas on your machine.
- **[Getting started guide](docs/getting_started.md)**: A guide to help you get started on using Enbas.
- **[Configuration reference](docs/configuration.md)**: The configuration reference documentation.
- **[User manual](docs/manual.md)**: The user manual.
- **[Tips and Tricks](docs/tips_and_tricks.md)**: Additional tips and tricks.
### Licensing
The licensing information associated with each file is specified in the [REUSE.toml](REUSE.toml) file,
but in general:
- All original source code is licensed under GPL-3.0-or-later.
- All documentation is licensed under CC-BY-4.0.
### Inspirations
This project was inspired by the following projects:
* **[madonctl](https://github.com/McKael/madonctl)**: A Mastodon CLI client written in Go.
* **[tut](https://github.com/RasmusLindroth/tut)**: A Mastodon TUI written in Go.
* **[toot](https://pypi.org/project/toot/)**: A Mastodon CLI and TUI written in Python.

View file

@ -1,42 +0,0 @@
# SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
#
# SPDX-License-Identifier: CC0-1.0
version = 1
SPDX-PackageName = "enbas"
SPDX-PackageSupplier = "Dan Anglin <d.n.i.anglin@gmail.com>"
SPDX-PackageDownloadLocation = "https://codeflow.dananglin.me.uk/apollo/enbas"
[[annotations]]
path = [
"**.go",
"go.mod",
"magefiles/go.mod",
"cmd/enbas-codegen/templates/**/*.go.gotmpl",
"schema/enbas_cli_schema.json",
".forgejo/workflows/*.yaml",
".forgejo/actions/**/action.yaml",
".forgejo/actions/**/Dockerfile",
]
precedence = "override"
SPDX-FileCopyrightText = "2024 Dan Anglin <d.n.i.anglin@gmail.com>"
SPDX-License-Identifier = "GPL-3.0-or-later"
[[annotations]]
path = [
"README.md",
"docs/*.md",
"assets/images/*.png",
]
precedence = "override"
SPDX-FileCopyrightText = "2024 Dan Anglin <d.n.i.anglin@gmail.com>"
SPDX-License-Identifier = "CC-BY-4.0"
[[annotations]]
path = [
"go.sum",
"magefiles/go.sum",
]
precedence = "override"
SPDX-FileCopyrightText = "2024 Dan Anglin <d.n.i.anglin@gmail.com>"
SPDX-License-Identifier = "CC0-1.0"

BIN
assets/images/consent_form.png (Stored with Git LFS)

Binary file not shown.

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
SPDX-License-Identifier: CC-BY-4.0

BIN
assets/images/created_poll.png (Stored with Git LFS)

Binary file not shown.

View file

@ -1,162 +0,0 @@
package main
import (
"embed"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"strings"
"text/template"
"unicode"
)
func main() {
var (
enbasCLISchemaFilepath string
packageName string
)
flag.StringVar(&enbasCLISchemaFilepath, "path-to-enbas-cli-schema", "", "The path to the Enbas CLI schema file")
flag.StringVar(&packageName, "package", "", "The name of the internal package")
flag.Parse()
schema, err := newEnbasCLISchemaFromFile(enbasCLISchemaFilepath)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: Unable to read the schema file: %v.\n", err)
os.Exit(1)
}
if err := generateExecutors(schema, packageName); err != nil {
fmt.Fprintf(os.Stderr, "ERROR: Unable to generate the executors: %v.\n", err)
os.Exit(1)
}
}
//go:embed templates/*
var executorTemplates embed.FS
var errNoPackageFlag = errors.New("the --package flag must be used")
func generateExecutors(schema enbasCLISchema, packageName string) error {
if packageName == "" {
return errNoPackageFlag
}
dirName := "templates/" + packageName
fsDir, err := executorTemplates.ReadDir(dirName)
if err != nil {
return fmt.Errorf("unable to read the template directory in the file system (FS): %w", err)
}
funcMap := template.FuncMap{
"capitalise": capitalise,
"flagFieldName": flagFieldName,
"getFlagType": schema.Flags.getType,
"getFlagDescription": schema.Flags.getDescription,
"internalFlagValue": internalFlagValue,
}
for _, obj := range fsDir {
templateFilename := obj.Name()
if !strings.HasSuffix(templateFilename, ".go.gotmpl") {
continue
}
if err := func() error {
tmpl := template.Must(template.New(templateFilename).
Funcs(funcMap).
ParseFS(executorTemplates, dirName+"/"+templateFilename),
)
output := strings.TrimSuffix(templateFilename, ".gotmpl")
file, err := os.Create(output)
if err != nil {
return fmt.Errorf("unable to create the output file: %w", err)
}
defer file.Close()
if err := tmpl.Execute(file, schema.Commands); err != nil {
return fmt.Errorf("unable to generate the code from the template: %w", err)
}
if err := runGoImports(output); err != nil {
return fmt.Errorf("unable to run goimports: %w", err)
}
return nil
}(); err != nil {
return fmt.Errorf("received an error after attempting to generate the code for %q: %w", templateFilename, err)
}
}
return nil
}
func runGoImports(path string) error {
imports := exec.Command("goimports", "-w", path)
if err := imports.Run(); err != nil {
return fmt.Errorf("received an error after running goimports: %w", err)
}
return nil
}
func capitalise(str string) string {
runes := []rune(str)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
func flagFieldName(flagRef enbasCLISchemaFlagReference) string {
if flagRef.FieldName != "" {
return flagRef.FieldName
}
return convertFlagToMixedCaps(flagRef.Flag)
}
func convertFlagToMixedCaps(value string) string {
var builder strings.Builder
runes := []rune(value)
numRunes := len(runes)
cursor := 0
for cursor < numRunes {
if runes[cursor] != '-' {
builder.WriteRune(runes[cursor])
cursor++
} else {
if cursor != numRunes-1 && unicode.IsLower(runes[cursor+1]) {
builder.WriteRune(unicode.ToUpper(runes[cursor+1]))
cursor += 2
} else {
cursor++
}
}
}
return builder.String()
}
func internalFlagValue(flagType string) bool {
internalFlagValues := map[string]struct{}{
"StringSliceValue": {},
"IntSliceValue": {},
"TimeDurationValue": {},
"BoolPtrValue": {},
}
_, exists := internalFlagValues[flagType]
return exists
}

View file

@ -1,72 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
)
type enbasCLISchema struct {
Flags enbasCLISchemaFlagMap `json:"flags"`
Commands map[string]enbasCLISchemaCommand `json:"commands"`
}
func newEnbasCLISchemaFromFile(path string) (enbasCLISchema, error) {
file, err := os.Open(path)
if err != nil {
return enbasCLISchema{}, fmt.Errorf("unable to open the schema file: %w", err)
}
defer file.Close()
var schema enbasCLISchema
if err := json.NewDecoder(file).Decode(&schema); err != nil {
return enbasCLISchema{}, fmt.Errorf("unable to decode the JSON data: %w", err)
}
return schema, nil
}
type enbasCLISchemaFlag struct {
Type string `json:"type"`
Description string `json:"description"`
}
type enbasCLISchemaFlagMap map[string]enbasCLISchemaFlag
func (e enbasCLISchemaFlagMap) getType(name string) string {
flag, ok := e[name]
if !ok {
return "UNKNOWN TYPE"
}
return flag.Type
}
func (e enbasCLISchemaFlagMap) getDescription(name string) string {
flag, ok := e[name]
if !ok {
return "UNKNOWN DESCRIPTION"
}
return flag.Description
}
type enbasCLISchemaCommand struct {
AdditionalFields []enbasCLISchemaAdditionalFields `json:"additionalFields"`
Flags []enbasCLISchemaFlagReference `json:"flags"`
Summary string `json:"summary"`
UseConfig bool `json:"useConfig"`
UsePrinter bool `json:"usePrinter"`
}
type enbasCLISchemaFlagReference struct {
Flag string `json:"flag"`
FieldName string `json:"fieldName"`
Default string `json:"default"`
}
type enbasCLISchemaAdditionalFields struct {
Name string `json:"name"`
Type string `json:"type"`
}

View file

@ -1,175 +0,0 @@
/*
This file is generated by the enbas-codegen
DO NOT EDIT.
*/
{{ print "" }}
package executor
{{ print "" }}
{{ print "" }}
import "fmt"
import "flag"
import "os"
import "codeflow.dananglin.me.uk/apollo/enbas/internal/config"
import internalFlag "codeflow.dananglin.me.uk/apollo/enbas/internal/flag"
import "codeflow.dananglin.me.uk/apollo/enbas/internal/printer"
import "codeflow.dananglin.me.uk/apollo/enbas/internal/usage"
{{ print "" }}
{{ print "" }}
func Execute() error {
var (
configDir string
noColorFlag internalFlag.BoolPtrValue
noColor bool
)
flag.StringVar(&configDir, "config-dir", "", "The path to your configuration directory")
flag.Var(&noColorFlag, "no-color", "Set to true to disable ANSI colour output when displaying text on screen")
flag.Usage = usage.AppUsageFunc()
flag.Parse()
if flag.NArg() < 1 {
flag.Usage()
return nil
}
if noColorFlag.Value != nil {
noColor = *noColorFlag.Value
} else if os.Getenv("NO_COLOR") != "" {
noColor = true
}
command := flag.Arg(0)
args := flag.Args()[1:]
executorMap := map[string]func(string, bool, []string) error {
{{- range $name, $command := . -}}
{{- $execute_command_function_name := capitalise $name | printf "Execute%sCommand" -}}
{{ print "" }}
{{ printf "%q" $name }}: {{ $execute_command_function_name }},
{{- end -}}
{{ print "" }}
}
executorFunc, ok := executorMap[command]
if !ok {
err := UnknownCommandError{command: command}
printer.NewPrinter(noColor, "", 0).PrintFailure("Error: "+err.Error())
return err
}
return executorFunc(configDir, noColor, args)
}
{{ print "" }}
{{ range $name, $command := . }}
{{- $struct_name := capitalise $name | printf "%sExecutor" -}}
{{- $execute_command_function_name := capitalise $name | printf "Execute%sCommand" -}}
{{ print "" }}
// {{ $struct_name }} is the executor for the {{ $name }} command.
type {{ $struct_name }} struct {
*flag.FlagSet
printer *printer.Printer
config *config.Config
{{- range $flag := $command.Flags -}}
{{- $flag_type := getFlagType $flag.Flag -}}
{{- if internalFlagValue $flag_type -}}
{{ print "" }}
{{ flagFieldName $flag }} internalFlag.{{ $flag_type }}
{{- else -}}
{{ print "" }}
{{ flagFieldName $flag }} {{ $flag_type }}
{{- end -}}
{{- end -}}
{{- range $field := $command.AdditionalFields -}}
{{ print "" }}
{{ $field.Name }} {{ $field.Type }}
{{- end -}}
{{ print "" }}
}
// {{ $execute_command_function_name }} initialises and runs the executor for the {{ $name }} command.
func {{ $execute_command_function_name }}(
configDir string,
noColor bool,
args []string,
) error {
exe := {{ $struct_name }}{
FlagSet: flag.NewFlagSet({{ printf "%q" $name }}, flag.ExitOnError),
printer: nil,
config: nil,
configDir: configDir,
{{- range $flag := $command.Flags -}}
{{- $flag_type := getFlagType $flag.Flag -}}
{{- if internalFlagValue $flag_type -}}
{{ print "" }}
{{ flagFieldName $flag }}: internalFlag.New{{ $flag_type }}(),
{{- end -}}
{{- end -}}
{{ print "" }}
}
{{ print "" }}
exe.Usage = usage.ExecutorUsageFunc({{ printf "%q" $name }}, {{ printf "%q" $command.Summary }}, exe.FlagSet)
{{ print "" }}
{{- range $flag := $command.Flags -}}
{{- $flag_type := getFlagType $flag.Flag -}}
{{- if eq $flag_type "string" -}}
{{ print "" }}
exe.StringVar(&exe.{{ flagFieldName $flag }}, {{ printf "%q" $flag.Flag }}, {{ printf "%q" $flag.Default }}, {{ getFlagDescription $flag.Flag | printf "%q" }})
{{- else if eq $flag_type "bool" -}}
{{ print "" }}
exe.BoolVar(&exe.{{ flagFieldName $flag }}, {{ printf "%q" $flag.Flag }}, {{ $flag.Default }}, {{ getFlagDescription $flag.Flag | printf "%q" }})
{{- else if eq $flag_type "int" -}}
{{ print "" }}
exe.IntVar(&exe.{{ flagFieldName $flag }}, {{ printf "%q" $flag.Flag }}, {{ $flag.Default }}, {{ getFlagDescription $flag.Flag | printf "%q" }})
{{- else if internalFlagValue $flag_type -}}
{{ print "" }}
exe.Var(&exe.{{ flagFieldName $flag }}, {{ printf "%q" $flag.Flag }}, {{ getFlagDescription $flag.Flag | printf "%q" }})
{{- end -}}
{{- end -}}
{{ print "" }}
{{ print "" }}
// Parse the remaining arguments.
if err := exe.Parse(args); err != nil {
printer.NewPrinter(noColor, "", 0).PrintFailure("({{ $name }}) flag parsing error: " + err.Error() + ".")
return err
}
{{- if $command.UseConfig -}}
{{ print "" }}
{{ print "" }}
// Load the configuration from file.
exeConfig, err := config.NewConfigFromFile(exe.configDir)
if err != nil {
printer.NewPrinter(noColor, "", 0).PrintFailure("({{ $name }}) unable to load configuration: " + err.Error() + ".")
return err
}
exe.config = exeConfig
// Create the printer for the executor.
exe.printer = printer.NewPrinter(
noColor,
exe.config.Integrations.Pager,
exe.config.LineWrapMaxWidth,
)
{{- else -}}
{{ print "" }}
{{ print "" }}
// Create the printer for the executor.
exe.printer = printer.NewPrinter(noColor, "", 0)
{{- end -}}
{{ print "" }}
{{ print "" }}
// Run the executor.
if err := exe.Execute(); err != nil {
exe.printer.PrintFailure("({{ $name }}) execution error: " + err.Error() + ".")
return err
}
return nil
}
{{ end }}

View file

@ -1,14 +0,0 @@
/*
This file is generated by the enbas-codegen
DO NOT EDIT.
*/
{{ print "" }}
package usage
{{ print "" }}
var summaries = map[string]string {
{{- range $name, $command := . -}}
{{ print "" }}
{{ printf "%q" $name }}: {{ printf "%q" $command.Summary }},
{{- end -}}
{{ print "" }}
}

13
cmd/enbas/errors.go Normal file
View file

@ -0,0 +1,13 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package main
type unknownCommandError struct {
subcommand string
}
func (e unknownCommandError) Error() string {
return "unknown command '" + e.subcommand + "'"
}

View file

@ -1,13 +1,207 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package main package main
import ( import (
"flag"
"fmt"
"os" "os"
"strconv"
"codeflow.dananglin.me.uk/apollo/enbas/internal/executor" "codeflow.dananglin.me.uk/apollo/enbas/internal/executor"
) )
const (
commandLogin string = "login"
commandVersion string = "version"
commandShow string = "show"
commandSwitch string = "switch"
commandCreate string = "create"
commandDelete string = "delete"
commandEdit string = "edit"
commandWhoami string = "whoami"
commandAdd string = "add"
commandRemove string = "remove"
commandFollow string = "follow"
commandUnfollow string = "unfollow"
commandBlock string = "block"
commandUnblock string = "unblock"
)
var (
binaryVersion string
buildTime string
goVersion string
gitCommit string
)
func main() { func main() {
if err := executor.Execute(); err != nil { if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v.\n", err)
os.Exit(1) os.Exit(1)
} }
} }
func run() error {
commandSummaries := map[string]string{
commandLogin: "login to an account on GoToSocial",
commandVersion: "print the application's version and build information",
commandShow: "print details about a specified resource",
commandSwitch: "perform a switch operation (e.g. switch logged in accounts)",
commandCreate: "create a specific resource",
commandDelete: "delete a specific resource",
commandEdit: "edit a specific resource",
commandWhoami: "print the account that you are currently logged in to",
commandAdd: "add a resource to another resource",
commandRemove: "remove a resource from another resource",
commandFollow: "follow a resource (e.g. an account)",
commandUnfollow: "unfollow a resource (e.g. an account)",
commandBlock: "block a resource (e.g. an account)",
commandUnblock: "unblock a resource (e.g. an account)",
}
topLevelFlags := executor.TopLevelFlags{
ConfigDir: "",
NoColor: nil,
}
flag.StringVar(&topLevelFlags.ConfigDir, "config-dir", "", "specify your config directory")
flag.BoolFunc("no-color", "disable ANSI colour output when displaying text on screen", func(value string) error {
boolVal, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("unable to parse %q as a boolean: %w", value, err)
}
topLevelFlags.NoColor = new(bool)
*topLevelFlags.NoColor = boolVal
return nil
})
flag.Usage = usageFunc(commandSummaries)
flag.Parse()
if flag.NArg() < 1 {
flag.Usage()
return nil
}
// If NoColor is still unspecified, check to see if the NO_COLOR environment variable is set
if topLevelFlags.NoColor == nil {
topLevelFlags.NoColor = new(bool)
if os.Getenv("NO_COLOR") != "" {
*topLevelFlags.NoColor = true
} else {
*topLevelFlags.NoColor = false
}
}
command := flag.Arg(0)
args := flag.Args()[1:]
var err error
switch command {
case commandAdd:
exe := executor.NewAddExecutor(
topLevelFlags,
commandAdd,
commandSummaries[commandAdd],
)
err = executor.Execute(exe, args)
case commandBlock:
exe := executor.NewBlockExecutor(
topLevelFlags,
commandBlock,
commandSummaries[commandBlock],
false,
)
err = executor.Execute(exe, args)
case commandCreate:
exe := executor.NewCreateExecutor(
topLevelFlags,
commandCreate,
commandSummaries[commandCreate],
)
err = executor.Execute(exe, args)
case commandDelete:
exe := executor.NewDeleteExecutor(
topLevelFlags,
commandDelete,
commandSummaries[commandDelete],
)
err = executor.Execute(exe, args)
case commandEdit:
exe := executor.NewEditExecutor(
topLevelFlags,
commandEdit,
commandSummaries[commandEdit],
)
err = executor.Execute(exe, args)
case commandFollow:
exe := executor.NewFollowExecutor(
topLevelFlags,
commandFollow,
commandSummaries[commandFollow],
false,
)
err = executor.Execute(exe, args)
case commandLogin:
exe := executor.NewLoginExecutor(
topLevelFlags,
commandLogin,
commandSummaries[commandLogin],
)
err = executor.Execute(exe, args)
case commandRemove:
exe := executor.NewRemoveExecutor(
topLevelFlags,
commandRemove,
commandSummaries[commandRemove],
)
err = executor.Execute(exe, args)
case commandSwitch:
exe := executor.NewSwitchExecutor(
topLevelFlags,
commandSwitch,
commandSummaries[commandSwitch],
)
err = executor.Execute(exe, args)
case commandUnfollow:
exe := executor.NewFollowExecutor(topLevelFlags, commandUnfollow, commandSummaries[commandUnfollow], true)
err = executor.Execute(exe, args)
case commandUnblock:
exe := executor.NewBlockExecutor(topLevelFlags, commandUnblock, commandSummaries[commandUnblock], true)
err = executor.Execute(exe, args)
case commandShow:
exe := executor.NewShowExecutor(topLevelFlags, commandShow, commandSummaries[commandShow])
err = executor.Execute(exe, args)
case commandVersion:
exe := executor.NewVersionExecutor(
commandVersion,
commandSummaries[commandVersion],
binaryVersion,
buildTime,
goVersion,
gitCommit,
)
err = executor.Execute(exe, args)
case commandWhoami:
exe := executor.NewWhoAmIExecutor(topLevelFlags, commandWhoami, commandSummaries[commandWhoami])
err = executor.Execute(exe, args)
default:
flag.Usage()
return unknownCommandError{command}
}
if err != nil {
return fmt.Errorf("(%s) %w", command, err)
}
return nil
}

50
cmd/enbas/usage.go Normal file
View file

@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"flag"
"fmt"
"slices"
"strings"
)
func usageFunc(summaries map[string]string) func() {
cmds := make([]string, len(summaries))
ind := 0
for k := range summaries {
cmds[ind] = k
ind++
}
slices.Sort(cmds)
return func() {
var builder strings.Builder
builder.WriteString("SUMMARY:\n enbas - A GoToSocial client for the terminal.\n\n")
if binaryVersion != "" {
builder.WriteString("VERSION:\n " + binaryVersion + "\n\n")
}
builder.WriteString("USAGE:\n enbas [flags]\n enbas [command]\n\nCOMMANDS:")
for _, cmd := range cmds {
fmt.Fprintf(&builder, "\n %s\t%s", cmd, summaries[cmd])
}
builder.WriteString("\n\nFLAGS:\n --help\n print the help message\n")
flag.VisitAll(func(f *flag.Flag) {
fmt.Fprintf(&builder, "\n --%s\n %s\n", f.Name, f.Usage)
})
builder.WriteString("\nUse \"enbas [command] --help\" for more information about a command.\n")
w := flag.CommandLine.Output()
fmt.Fprint(w, builder.String())
}
}

View file

@ -1 +0,0 @@
# Changelog

View file

@ -1,29 +0,0 @@
# Configuration reference
## Config
| Field | Type | Description |
|--------------------|-------------------------------|--------------------------------------------------|
| `credentialsFile` | string | The (absolute) path to your credentials file. |
| `cacheDirectory` | string | The (absolute) path to the root cache directory. |
| `lineWrapMaxWidth` | int | The character limit used for line wrapping. |
| `http` | [HTTPConfig](#httpconfig) | HTTP settings. |
| `integrations` | [Integrations](#integrations) | Specify your integrations with Enbas. |
## HTTPConfig
| Field | Type | Description |
|----------------|------|---------------------------------------------------------------------|
| `timeout` | int | The timeout (in seconds) for normal HTTP requests to your instance. |
| `mediaTimeout` | int | The timeout (in seconds) for retrieving media from your instance. |
## Integrations
| Field | Type | Description |
|---------------|--------|--------------------------------------------------------------------------------------------------------|
| `browser` | string | The browser used for opening URLs (e.g. URL to a remote account). |
| `editor` | string | The text editor used for writing statuses (not yet implemented). |
| `pager` | string | The pager used for paging through long outputs (e.g. status timelines). Leave blank to disable paging. |
| `imageViewer` | string | The image viewer used for viewing images from media attachments. |
| `videoPlayer` | string | The video player used for viewing videos from media attachments. |

View file

@ -1,152 +0,0 @@
# Getting Started
## Summary
In this guide we are going to log into an account on a private GoToSocial server.
Follow along to log into your own account.
## Your configuration directory
You can use the `--config-dir` global flag to specify the path to your configuration directory.
Alternatively Enbas tries to set the directory based on your home configuration directory using Go's [os.UserConfigDir()](https://pkg.go.dev/os#UserConfigDir) function.
If you've set the `XDG_CONFIG_HOME` environment variable, the configuration directory will be set to `$XDG_CONFIG_HOME/enbas`.
If this is not set, then:
- on Linux the configuration directory will be set to `$HOME/.config/enbas`.
- on Darwin (MacOS) the configuration directory will be set to `$HOME/Library/Application Support/enbas`.
- on Windows the configuration directory will be set within the `%AppData%` directory.
## Generate your configuration file
Run the `init` command to generate your configuration file.
```bash
enbas init
```
Use the `--config-dir` flag if you want to generate it in a specific directory
```bash
enbas --config-dir ./config init
```
You should now see a file called `config.json` in your configuration directory.
Feel free to edit the file to your preferences.
The [configuration reference page](./configuration.md) should help you with this.
For this 'Getting Started' guide you may want to specify your preferred browser in the configuration to allow
Enbas to open the link to your instance's authorisation page.
If you prefer to open the link manually then you can leave it blank.
## Log into your GoToSocial account
Enbas uses the Oauth2 authentication flow to log into your account on GoToSocial.
> [!WARNING]
> As of writing GoToSocial does not currently support scoped authorization tokens so even if we request read-only
> tokens, the application will be able to perform any actions within the limitations of your account
> (including admin actions if you are an admin).
> You can read more about this [here](https://docs.gotosocial.org/en/latest/api/authentication/).
Follow the below steps to log into your account:
1. Run the `login` command specifying the instance that you want to log into.
```bash
enbas login --instance gts.enbas-demo.private
```
2. Enbas will send a registration request to your instance and receive a new client ID and secret that it
needs for authentication.
3. Enbas will then generate a link to the consent form for you to access in your browser and print it to
your terminal screen along with a message explaining that you need to obtain the `out-of-band` token
to continue.
The link will open in a tab in your preferred browser if you've specified it in your configuration,
otherwise you can manually open it yourself.
If the browser tab doesn't open for you as expected you can still manually open it yourself.
4. Once you've signed into GoToSocial on your browser,
you will be informed that Enbas would like to perform actions on your behalf.
If you're happy with this then click on the `Allow` button.
![A screenshot of the consent form](../assets/images/consent_form.png "Consent Form")
5. The `out-of-band` token from your instance will be displayed to you in your browser.
Copy it and return to your terminal.
6. Paste the token into the prompt and press `ENTER`.
Enbas will then exchange the token for an access token which will be used to authenticate
to your instance on your behalf.
7. Enbas will then verify the access token, save the credentials to the `credentials.json` file
in your configuration directory, and inform you that you have successfully logged into your account.
### Example login flow
```
$ enbas login --instance gts.enbas-demo.private
You'll need to sign into your GoToSocial's consent page in order to generate the out-of-band token to continue with the application's login process.
Your browser may have opened the link to the consent page already. If not, please copy and paste the link below to your browser:
https://gts.enbas-demo.private/oauth/authorize?client_id=019RD0WVA903F773T5F9D9EYHP&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob&response_type=code
Once you have the code please copy and paste it below.
Out-of-band token: ZDRKOTE0NMUTZGVHZC0ZNJVJLWJINTMTMWE1M2UWYWFHOTQY
âś” You have successfully logged as percy@gts.enbas-demo.private.
```
## View your account information
You can verify that you have successfully logged in by viewing your account information.
```bash
enbas show --type account --my-account
```
### Example
```
$ enbas show --type account --my-account
Percy Cade (@percy)
ACCOUNT ID:
01629QXYN8X597CZDAH4BTY32R
JOINED ON:
29 Jun 2024
STATS:
Followers: 0
Following: 0
Statuses: 0
BIOGRAPHY:
Hey there, the name's Percy.
I've been a Platform Engineer in the Healthcare industry for 5 years and
counting. I love containerising anything and everything with Docker and
Kubernetes, and often find myself dabbling with Python, Go and Rust.
In my free time I like to cook, blog about FOSS software news and make videos
documenting my travels across the UK.
METADATA:
Pronouns: he/him
Location: Hertfordshire, UK
My website: https://percycade.me.private
My blogs: https://blogs.percycade.me.private
My photos: https://photos.percycade.me.private
My videos: https://videos.percycade.me.private
ACCOUNT URL:
https://gts.enbas-demo.private/@percy
```
Now that you have successfully logged into your account proceed to the [user manual](./manual.md).

View file

@ -1,118 +0,0 @@
# Installation Guide
## Download
Pre-built binaries will soon be available on the release page on both Codeberg and GitHub.
## Build from source
### Build requirements
- **Go:** A minimum version of Go 1.23.0 is required for installing Enbas.
Please go [here](https://go.dev/dl/) to download the latest version.
- **Mage (Optional):** The project includes mage targets for building and installing the binary. The main
advantage of using mage over `go install` is that the build information is baked into the binary during
compilation. You can visit the [Mage website](https://magefile.org/) for instructions on how to install Mage.
### Install with mage
You can install Enbas with Mage using the following commands:
```bash
git clone https://github.com/dananglin/enbas.git
mage install
```
By default Mage will attempt to install Enbas to `/usr/local/bin/enbas` which will most likely fail as you won't
the permission to write to `/usr/local/bin/`. You will need to either run `sudo mage install`, or you can
(preferably) change the install prefix to a directory that you have permission to write to using
the `ENBAS_INSTALL_PREFIX` environment variable. For example:
```bash
ENBAS_INSTALL_PREFIX=${HOME}/.local mage install
```
This will install Enbas to `~/.local/bin/enbas`.
The table below shows all the environment variables you can use when building with Mage.
| Environment Variable | Description |
|--------------------------|------------------------------------------------------------------------------|
|`ENBAS_INSTALL_PREFIX` | Set this to your preferred the installation prefix (default: `/usr/local`). |
|`ENBAS_BUILD_REBUILD_ALL` | Set this to `1` to rebuild all packages even if they are already up-to-date. |
|`ENBAS_BUILD_VERBOSE` | Set this to `1` to enable verbose logging when building the binary. |
### Install with go
If your `GOBIN` directory is included in your `PATH` then you can install Enbas with Go.
```bash
git clone https://github.com/dananglin/enbas.git
cd enbas
go install ./cmd/enbas
```
## Verify the installation
Type `enbas` from your terminal to verify that the installation was successful. You should see the help documentation.
```
$ enbas
SUMMARY:
enbas - A GoToSocial client for the terminal.
VERSION:
v0.2.0
USAGE:
enbas [flags]
enbas [flags] [command]
COMMANDS:
accept Accepts a request (e.g. a follow request)
add Adds a resource to another resource
block Blocks a resource (e.g. an account)
create Creates a specific resource
delete Deletes a specific resource
edit Edit a specific resource
follow Follow a resource (e.g. an account)
init Creates a new configuration file in the specified configuration directory
login Logs into an account on GoToSocial
mute Mutes a specific resource (e.g. an account)
reject Rejects a request (e.g. a follow request)
remove Removes a resource from another resource
show Shows details about a specified resource
switch Performs a switch operation (e.g. switching between logged in accounts)
unblock Unblocks a resource (e.g. an account)
unfollow Unfollows a resource (e.g. an account)
unmute Umutes a specific resource (e.g. an account)
version Prints the application's version and build information
whoami Prints the account that you are currently logged into
FLAGS:
--help
print the help message
--config-dir
The path to your configuration directory
--no-color
Set to true to disable ANSI colour output when displaying text on screen
Use "enbas [command] --help" for more information about a command.
```
You can also view the application's version and build information using the `version` command.
The build information is correctly displayed if you've downloaded the binary from Codeberg or GitHub,
or if you've built it with Mage.
```bash
$ enbas version --full
Enbas
Version: v0.2.0
Git commit: fa58e5b
Go version: go1.23.0
Build date: 2024-08-29T07:24:53Z
```
Once you have completed the installation proceed to the [Getting Started guide](./getting_started.md).

View file

@ -1,875 +0,0 @@
# User Manual
## Table of Contents
- [Global flags](#global-flags)
- [Version](#version)
- [Print the application version](#print-the-application-version)
- [Init](#init)
- [Authentication](#authentication)
- [Logging into an account](#logging-into-an-account)
- [Switch between accounts](#switch-between-accounts)
- [See the account that you are currently logged in as](#see-the-account-that-you-are-currently-logged-in-as)
- [Accounts](#accounts)
- [View account information](#view-account-information)
- [Follow an account](#follow-an-account)
- [Unfollow an account](#unfollow-an-account)
- [Show an account's followers](#show-an-accounts-followers)
- [Show account's followings](#show-accounts-followings)
- [Block an account](#block-an-account)
- [Unblock an account](#unblock-an-account)
- [View blocked accounts](#view-blocked-accounts)
- [Mute an account](#mute-an-account)
- [Unmute an account](#unmute-an-account)
- [View muted accounts](#view-muted-accounts)
- [Add a private note to an account](#add-a-private-note-to-an-account)
- [Remove the private note from an account](#remove-the-private-note-from-an-account)
- [Follow requests](#follow-requests)
- [View your follow requests](#view-your-follow-requests)
- [Accept a follow request](#accept-a-follow-request)
- [Reject a follow request](#reject-a-follow-request)
- [Media Attachments](#media-attachments)
- [Create a media attachment](#create-a-media-attachment)
- [Edit a media attachment](#edit-a-media-attachment)
- [View a media attachment](#view-a-media-attachment)
- [Statuses](#statuses)
- [View a status](#view-a-status)
- [Create a status](#create-a-status)
- [Delete a status](#delete-a-status)
- [Boost (Repost) a status](#boost-repost-a-status)
- [Un-boost (Un-repost) a status](#un-boost-un-repost-a-status)
- [Like a status](#like-a-status)
- [Unlike a status](#unlike-a-status)
- [View a list of statuses that you've liked](#view-a-list-of-statuses-that-youve-liked)
- [Mute a status](#mute-a-status)
- [Unmute a status](#unmute-a-status)
- [Vote in a poll within a status](#vote-in-a-poll-within-a-status)
- [Polls](#polls)
- [Create a poll](#create-a-poll)
- [View a poll](#view-a-poll)
- [Vote in a poll](#vote-in-a-poll)
- [Lists](#lists)
- [Create a list](#create-a-list)
- [View a list of your lists](#view-a-list-of-your-lists)
- [View a specific list](#view-a-specific-list)
- [Edit a list](#edit-a-list)
- [Delete a list](#delete-a-list)
- [Add accounts to a list](#add-accounts-to-a-list)
- [Remove accounts from a list](#remove-accounts-from-a-list)
- [Timelines](#timelines)
- [View a timeline](#view-a-timeline)
- [Media](#media)
- [View media from a status](#view-media-from-a-status)
- [Bookmarks](#bookmarks)
- [View your bookmarks](#view-your-bookmarks)
- [Add a status to your bookmarks](#add-a-status-to-your-bookmarks)
- [Remove a status from your bookmarks](#remove-a-status-from-your-bookmarks)
- [Notifications](#notifications)
## Global flags
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `config-dir` | string | false | The configuration directory. | |
| `no-color` | boolean | false | Disables ANSI colour output when displaying text on screen<br>You can also set `NO_COLOR` to any value for the same effect. | false |
## Version
### Print the application version
View the application's version and build information
```
enbas version --full
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `full` | boolean | false | Prints the full build information. | false |
## Init
Initialises the app by creating a configuration file in the configuration directory.
If you want to use a specific directory then use the global `--config-dir` flag.
```
enbas init
```
## Authentication
### Logging into an account
Log into your GoToSocial account. You can run this multiple times to log into multiple accounts.
```
enbas login --instance gts.enbas-demo.private
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `instance` | string | true | The instance that you want to log into. | |
### Switch between accounts
Switch between your logged in accounts.
```
enbas switch --to account --account-name vincent@gts.enbas-demo.private
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `to` | string | true | The resource you want to switch to. In this case you want `account`. | |
| `account-name` | string | true | The name of the account you want to switch to. | |
### See the account that you are currently logged in as
```
enbas whoami
```
## Accounts
### View account information
- View information from your own account
```
enbas show --type account --my-account
```
- View information from a local or remote account.
```
enbas show --type account --account-name @name@example.social
```
- View an account and show the public statuses that it has created.
```
enbas show --type account --account-name @name@example.social --show-statuses --only-public
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to view. Here this should be `account`. | |
| `my-account` | boolean | true | Set to `true` to view your own account. | |
| `show-preferences` | boolean | false | Show your posting preferences. Only applicable with the `my-account` flag. | false |
| `account-name` | string | false | The name of the account to view. This is not required with the `my-account` flag. | |
| `skip-relationship` | boolean | false | Set to `true` to skip viewing your relationship to the account you are viewing (including the private note if you've created one). | false |
Additional flags for viewing an account's statuses.
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `show-statuses` | bool | false | Set to `true` to view the statuses created from this account. | false |
| `limit` | integer | false | The maximum amount of statuses to show from this account. | 20 |
| `exclude-replies` | bool | false | Set to `true` to exclude replies. | false |
| `exclude-boosts` | bool | false | Set to `true` to exclude boosts. | false |
| `only-pinned` | bool | false | Set to `true` to view only pinned statuses. | false |
| `only-media` | bool | false | Set to `true` to view only statuses with media attachments. | false |
| `only-public` | bool | false | Set to `true` to view only public statuses. | false |
### Follow an account
Sends a follow request to the account you want to follow.
```
enbas follow --type account --account-name @name@example.social
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to follow. Here this should be `account`. | |
| `account-name` | string | true | The name of the account to follow. | |
| `show-reposts` | boolean | false | Show reposts from the account you want to follow. | true |
| `notify` | boolean | false | Get notifications when the account you want to follow posts a status. | false |
### Unfollow an account
Unfollows the account that you are currently following.
If you have a follow request pending for the account in question,
performing an unfollow action will remove said follow request.
```
enbas unfollow --type account --account-name @name@example.social
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to unfollow. Here this should be `account`. | |
| `account-name` | string | true | The name of the account to unfollow. | |
### Show an account's followers
- View followers of your own account.
```
enbas show --type followers --from account --my-account
```
- View followers of another account.
```
enbas show --type followers --from account --account-name @name@example.social
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to view. Here this should be `followers`. | |
| `from` | string | true | The resource you want to view followers from.<br>Here this should be `account`. | |
| `my-account` | boolean | false | Set to `true` to view followers from your own account.<br>This takes precendence over `account-name`.| false |
| `account-name` | string | true | The name of the account to get the followers from. | |
### Show account's followings
- View the accounts that you are following.
```
enbas show --type following --from account --my-account
```
- View the accounts that another account is following.
```
enbas show --type following --from account --account-name @name@example.social
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to view. Here this should be `following`. | |
| `from` | string | true | The resource you want to view the followings from.<br>Here this should be `account`. | |
| `my-account` | boolean | false | Set to `true` to view the list from your own account.<br>This takes precendence over `account-name`.| false |
| `account-name` | string | true | The name of the account to get the list from. | |
### Block an account
```
enbas block --type account --account-name @name@example.social
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to block. Here this should be `account`. | |
| `account-name` | string | true | The name of the account to block. | |
### Unblock an account
```
enbas unblock --type account --account-name @name@example.social
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to unblock. Here this should be `account`. | |
| `account-name` | string | true | The name of the account to unblock. | |
### View blocked accounts
Prints a list of accounts that you are currently blocking.
```
enbas show --type blocked
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to view.<br>Here this should be `blocked` for blocked accounts. | |
| `limit` | integer | false | The maximum number of accounts to list. | 20 |
### Mute an account
- Mute an account indefinitely.
```
enbas mute --type account --account-name @name@example.social --mute-notifications"
```
- Mute an account for 1 and a half hours.
```
enbas mute --type account --account-name @name@example.social --mute-notifications --mute-duration="1 hour and 30 minutes"
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to mute.<br>Here this should be `account`. | |
| `account-name` | string | true | The name of the account to mute. | |
| `mute-notifications` | boolean | false | Set to `true` to mute notifications as well as statuses. | false |
| `mute-duration` | [time duration value](tips_and_tricks.md#the-time-duration-value) | false | Specify how long the account should be muted for.<br>Set to `0 seconds` to mute indefinitely | 0 seconds (indefinitely). |
### Unmute an account
```
enbas unmute --type account --account-name @name@example.social
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to unmute.<br>Here this should be `account`. | |
| `account-name` | string | true | The name of the account to unmute. | |
### View muted accounts
Prints a list of accounts that you have muted.
```
enbas show --type muted-accounts
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to view.<br>Here this should be `muted-accounts`. | |
| `limit` | integer | false | The maximum number of accounts to print. | 20 |
### Add a private note to an account
Adds a private note to an account. Private notes can only be viewed by you.
```
enbas add --type note --to account --account-name @name@example.social --content "This person is awesome."
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to add.<br>Here this should be `note`. | |
| `to` | string | true | The resource you want to add the note to.<br>Here this should be `account`. | |
| `account-name` | string | true | The name of the account that you want to add the note to. | |
| `content` | string | true | The content of the note. | |
### Remove the private note from an account
```
enbas remove --type note --from account --account-name @name@example.social
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to remove.<br>Here this should be `note`. | |
| `from` | string | true | The resource you want to remove the note to.<br>Here this should be `account`. | |
| `account-name` | string | true | The name of the account that you want to remove the note from. | |
## Follow requests
### View your follow requests
Prints a list of accounts that are requesting to follow you.
```
enbas show --type follow-request
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to view.<br>Here this should be `follow-request`. | |
| `limit` | integer | false | The maximum number of accounts to print. | 20 |
### Accept a follow request
Accepts the request from the account that wants to follow you.
```
enbas accept --type follow-request --account-name @person.example.social
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to accept.<br>Here this should be `follow-request`. | |
| `account-name` | string | true | The name of the account that you want to accept. | |
### Reject a follow request
Rejects the request from the account that wants to follow you.
```
enbas reject --type follow-request --account-name @person.example.social
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to accept.<br>Here this should be `follow-request`. | |
| `account-name` | string | true | The name of the account that you want to reject. | |
## Media Attachments
### Create a media attachment
Uploads media from a file to the instance and creates a media attachment.
You can write a description of the media in a text file and specify the path with the `media-description` flag (see the examples below).
- Create a media attachment with a simple description and a focus of x=-0.1, y=0.5
```
enbas create --type media-attachment \
--media-file picture.png \
--media-description "A picture of an old, slanted wooden bench in front of the woods." \
--media-focus "-0.1,0.5"
```
- Create a media attachment using a description written in the `description.txt` text file.
```
enbas create --type media-attachment \
--media-file picture.png \
--media-description file@description.txt
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to create.<br>Here this should be `media-attachment`. | |
| `media-file` | string | true | The path to the media file. | |
| `media-description` | string | false | The description of the media attachment which will be used as the media's alt-text.<br>To use a description from a text file, use the `flag@` prefix followed by the path to the file (e.g. `file@description.txt`)| |
| `media-focus` | string | false | The media's focus values. This should be in the form of two comma-separated numbers between -1 and 1 (e.g. 0.25,-0.34) | |
### Edit a media attachment
Edits the description and/or the focus of a media attachment that you own.
```
enbas edit --type media-attachment \
--attachment-id 01J5B9A8WFK59W11MS6AHPYWBR \
--media-description "An updated description of a picture."
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to edit.<br>Here this should be `media-attachment`. | |
| `media-description` | string | false | The description of the media attachment to edit.<br>To use a description from a text file, use the `flag@` prefix followed by the path to the file (e.g. `file@description.txt`)| |
| `media-focus` | string | false | The media's focus values. This should be in the form of two comma-separated numbers between -1 and 1 (e.g. 0.25,-0.34) | |
### View a media attachment
Prints information about a given media attachment that you own.
You can only see information about the media attachment that you own.
```
enbas show --type media-attachment --attachment-id 01J0N0RQSJ7CFGKHA30F7GBQXT
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to view.<br>Here this should be `media`. | |
| `attachment-id` | string | true | The ID of the media attachment to view. | |
## Statuses
### View a status
Prints information of a status on screen.
If the `--browser` flag is used, the link to the status is opened instead.
To enable browser support you must specify the browser in your configuration.
See the [configuration reference page](configuration.md#integration) on how to set up integration with
your browser if you have not done so already.
```
enbas show --type status --status-id 01J1Z9PT0243JT9QNQ5W96Z8CA
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to view.<br>Here this should be `status`. | |
| `status-id` | string | true | The ID of the status that you want to view. | |
| `browser` | boolean | false | Set to true to open the link to the status in your browser. | false |
### Create a status
Creates a new status.
- Create a simple status that is publicly visible.
```
enbas create --type status --content-type plain --visibility public --content "Hello, Fediverse!"
```
- Create a private status from a file.
```
enbas create --type status --content-type markdown --visibility private --content file@status.md
```
- Reply to another status.
```
enbas create --type status --in-reply-to 01J2A86E3M7WWH37H1QENT7CSH --content "@bernie thanks for this! Looking forward to trying this out."
```
- Create a status with a poll
```
enbas create \
--type status \
--content-type plain \
--visibility public \
--content "The age-old question: which text editor do you prefer?" \
--add-poll \
--poll-allows-multiple-choices=false \
--poll-expires-in "7 days" \
--poll-option "emacs" \
--poll-option "vim/neovim" \
--poll-option "nano" \
--poll-option "other (please comment)"
```
![A screenshot of a status with a poll](../assets/images/created_poll.png "A status with a poll")
- Create a status with a media attachment that you have created.
```
enbas create \
--type status \
--attachment-id 01J5BDHYJ7MWMMG76FP49H7SWD \
--content "I went out for a walk in the woods and found this interesting looking wooden bench."
```
- Upload and attach 4 media files to a new status. You must set the same number of `media-description` and `media-focus` flags **must** as the `media-file` flags.
The first `media-description` and `media-focus` flags correspond to the first `media-file` flag and so on.
```
enbas create --type status --visibility public \
--content "This post has a picture of a cat, a dog, a bee and a bird." \
--media-file cat.jpg --media-description file@cat.txt --media-focus "0,0" \
--media-file dog.jpg --media-description file@dog.txt --media-focus "-0.1,0.25" \
--media-file bee.jpg --media-description file@bee.txt --media-focus "1,1" \
--media-file bird.webp --media-description file@bird.txt --media-focus "0,0"
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to create.<br>Here this should be `status`. | |
| `attachment-id` | string | false | The ID of the media attachment to attach to the status.<br>Use this flag multiple times to attach multiple media. |
| `content` | string | false | The content of the status.<br>To read the content from a text file, use the `flag@` prefix followed by the path to the file (e.g. `file@status.md`).| |
| `content-type` | string | false | The format that the content is created in.<br>Valid values are `plain` and `markdown`. | plain |
| `enable-reposts` | boolean | false | The status can be reposted (boosted) by others. | true |
| `enable-federation` | boolean | false | The status can be federated beyond the local timelines. | true |
| `enable-likes` | boolean | false | The status can be liked (favourtied). | true |
| `enable-replies` | boolean | false | The status can be replied to. | true |
| `in-reply-to` | string | false | The ID of the status that you want to reply to. | |
| `language` | string | false | The ISO 639 language code that the status is written in.<br>If this is not specified then the default language from your posting preferences will be used. | |
| `media-file` | string | false | The path to the media file.<br>Use this flag multiple times to upload multiple media files. | |
| `media-description` | string | false | The description of the media attachment which will be used as the media's alt-text.<br>To use a description from a text file, use the `flag@` prefix followed by the path to the file (e.g. `file@description.txt`)<br>Use this flag multiple times to set multiple descriptions.| |
| `media-focus` | string | false | The media's focus values. This should be in the form of two comma-separated numbers between -1 and 1 (e.g. 0.25,-0.34).<br>Use this flag multiple times to set multiple focus values. | |
| `sensitive` | string | false | The status should be marked as sensitive.<br>If this is not specified then the default sensitivity from your posting preferences will be used. | |
| `summary` | string | false | The summary of the status (a.k.a the subject, spoiler text or content warning). | |
| `visibility` | string | false | The visibility of the status.<br>Valid values are `public`, `private`, `unlisted`, `mutuals_only` and `direct`.<br>If this is not specified then the default visibility from your posting preferences will be used. | |
Additional flags for polls.
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `add-poll` | boolean | false | Set to `true` to add a poll to the status. | false |
| `poll-allows-multiple-choices` | boolean | false | Set to `true` to allow users to make multiple choices. | false |
| `poll-hides-vote-counts` | boolean | false | Set to `true` to hide the vote count until the poll is closed. | false |
| `poll-option` | string | true | An option in the poll. Use this flag multiple times to set multiple options. | |
| `poll-expires-in` | [time duration value](tips_and_tricks.md#the-time-duration-value) | false | The duration in which the poll is open for. | |
### Delete a status
Deletes a status that belongs to you.
You can optionally save the text of the deleted status for redrafting purposes.
The saved text will be written to a text file within your cache directory.
```
enbas delete --type status --status-id 01J5B0N6DKZGYPQEZW9HWKV0VA
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to delete.<br>Here this should be `status`. | |
| `status-id` | string | true | The ID of the status that you want to delete. | |
| `save-text` | bool | false | Set to `true` to save the text of the deleted status. | false |
### Boost (Repost) a status
To boost a status, simply add a `boost` to it.
```
enbas add --type boost --to status --status-id 01J17FH1KD9CN6J9Q01011NE0D
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to add.<br>Here this should be `boost`. | |
| `to` | string | true | The resource you want to add the boost to.<br>Here this should be `status`. | |
| `status-id` | string | true | The ID of the status that you want to boost. | |
### Un-boost (Un-repost) a status
To un-boost a status that you've boosted, simply remove the `boost` from it.
```
enbas remove --type boost --from status --status-id 01J17FH1KD9CN6J9Q01011NE0D
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to add.<br>Here this should be `boost`. | |
| `from` | string | true | The resource you want to remove the boost from.<br>Here this should be `status`. | |
| `status-id` | string | true | The ID of the status that you want to un-boost. | |
### Like a status
To like (favourite) a status, simply add a `like` or a `star` to it.
```
enbas add --type star --to status --status-id 01J17FH1KD9CN6J9Q01011NE0D
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to add.<br>Here this should either be `like` or `star`. | |
| `to` | string | true | The resource you want to like.<br>Here this should be `status`. | |
| `status-id` | string | true | The ID of the status that you want to like. | |
### Unlike a status
To unlike (un-favourite) a status that you've previously liked, simply remove the `like` or `star` from it.
```
enbas remove --type star --from status --status-id 01J17FH1KD9CN6J9Q01011NE0D
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to add.<br>Here this should either be `like` or `star`. | |
| `from` | string | true | The resource you want to remove the like from.<br>Here this should be `status`. | |
| `status-id` | string | true | The ID of the status that you want to remove the like from. | |
### View a list of statuses that you've liked
Prints the list of statuses that you've liked.
```
enbas show --type liked
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to view.<br>Here this should either be `liked` or `starred`. | |
| `limit` | integer | false | The maximum number of statuses to print. | 20 |
### Mute a status
Mutes a status in order to stop receiving future notifications for replies, likes, boosts, etc.
```
enbas mute --type status --status-id 01J56ZJAGEWG967GS1EK0TV3GA
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to mute.<br>Here this should be `status`. | |
| `status-id` | string | true | The ID of the status that you want to mute. | |
### Unmute a status
Unmute a status in order to start receiving future notification from the status' thread.
```
enbas mute --type status --status-id 01J56ZJAGEWG967GS1EK0TV3GA
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to unmute.<br>Here this should be `status`. | |
| `status-id` | string | true | The ID of the status that you want to unmute. | |
### Vote in a poll within a status
Adds your vote(s) to a poll within a status.
```
enbas add --type vote --to status --status-id 01J55XVV2MM6MKQ7QHFBAVAE8R --vote 3
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to add.<br>Here this should be `vote`. | |
| `to` | string | true | The resource you want to add the vote to.<br>Here this should be `status`. | |
| `status-id` | string | true | The ID of the poll you want to add the votes to. | |
| `vote` | int | true | The ID of the option that you want to vote for.<br>You can use this flag multiple times to vote for more than one option if the poll allows multiple choices. | |
## Polls
### Create a poll
See [Create a status](#create-a-status).
### View a poll
You can view a poll within a [status](#view-a-status) or within a [timeline](#view-a-timeline).
### Vote in a poll
See [Vote in a poll within a status](#vote-in-a-poll-within-a-status)
## Lists
### Create a list
```
enbas create --type list --list-title "My Favourite People" --list-replies-policy list
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to create.<br>Here this should be `list`. | |
| `list-title` | string | true | The title of the list that you want to create. | |
| `list-replies-policy` | string | false | The policy of the replies for this list.<br>Valid values are `followed`, `list` and `none`. | list |
### View a list of your lists
```
enbas show --type list
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to view.<br>Here this should be `list`. | |
### View a specific list
Prints the information of the specified list to screen along with all the accounts added to it (if any).
```
enbas show --type list --list-id 01J1T9DWR20DC36QWZFKHWZJ3H
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to view.<br>Here this should be `list`. | |
| `list-id` | string | false | The ID of the list you want to view. If this is not specified then a list of your lists will be printed instead. | |
### Edit a list
Edits the title and/or the replies policy of a list.
```
enbas edit --type list --list-id 01J1T9DWR20DC36QWZFKHWZJ3H --list-title "My Favourite People (in the world)"
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to edit.<br>Here this should be `list`. | |
| `list-title` | string | false | The title of the list that you want to edit. | |
| `list-replies-policy` | string | false | The policy of the replies for this list that you want to change to.<br>Valid values are `followed`, `list` and `none`. | |
### Delete a list
Deletes a list.
```
enbas delete --type list --list-id 01J1T9DWR20DC36QWZFKHWZJ3H
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to delete.<br>Here this should be `list`. | |
| `list-id` | string | true | The ID of the list you want to delete. | |
### Add accounts to a list
Adds one or more accounts to a list.
```
enbas add --type account --account-name @name@example.social --account-name @person@mastodon.example --to list --list-id 01J1T9DWR20DC36QWZFKHWZJ3H
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to add.<br>Here this should be `account`. | |
| `account-name` | string | true | The name of the account you want to add to the list.<br>Use multiple times to specify multiple accounts. | |
| `to` | string | true | The resource you want to add the accounts to.<br>Here this should be `list`. | |
| `list-id` | string | true | The ID of the list that you want to add the accounts to. | |
### Remove accounts from a list
Removes one or more accounts from a list.
```
enbas remove --type account --account-name @person@mastodon.example --from list --list-id 01J1T9DWR20DC36QWZFKHWZJ3H
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to add.<br>Here this should be `account`. | |
| `account-name` | string | true | The name of the account you want to remove from the list.<br>Use multiple times to specify multiple accounts. | |
| `to` | string | true | The resource you want to remove the accounts from.<br>Here this should be `list`. | |
| `list-id` | string | true | The ID of the list that you want to remove the accounts from. | |
## Timelines
### View a timeline
Prints a list of statuses from a timeline.
- View your home timeline.
```
enbas show --type timeline --timeline-category home
```
- View a maximum of 5 statuses from your instance's public timeline.
```
enbas show --type timeline --timeline-category public --limit 5
```
- View a timeline from one of your lists.
```
enbas show --type timeline --timeline-category list --list-id 01J1T9DWR20DC36QWZFKHWZJ3H
```
- View a timeline from a hashtag.
```
enbas show --type timeline --timeline-category tag --tag caturday
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to view.<br>Here this should be `timeline`. | |
| `timeline-category` | string | false | The type of timeline you want to view.<br>Valid values are `home`, `public`, `list` and `tag`. | home |
| `list-id` | string | false | The ID of the list you want to view.<br>This is only required if `timeline-category` is set to `list`. | |
| `tag` | string | false | The hashtag you want to view.<br>This is only required if `timeline-category` is set to `tag`. | |
| `limit` | integer | false | The maximum number of statuses to print. | 20 |
## Media
### View media from a status
Downloads and opens media attachment(s) from a status.
Enbas currently supports viewing images and videos.
The media is downloaded to your cache directory before Enbas opens it with your preferred media player.
In order to view images and videos, you must specify your image viewer and
video player in your configuration file respectively.
See the [configuration reference page](configuration.md#integration) on how to set up integration with
your media players.
- View a specific media attachment from a specific status
```
enbas show --type media --from status --status-id 01J0N11V4V7PWH0DDRAVT7TCFK --attachment-id 01J0N0RQSJ7CFGKHA30F7GBQXT
```
- View all image attachments from a specific status
```
enbas show --type media --from status --status-id 01J0N11V4V7PWH0DDRAVT7TCFK --all-images
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to view.<br>Here this should be `media`. | |
| `from` | string | true | The resource you want to view the media from.<br>Here this should be `status`. | |
| `status-id` | string | true | The ID of the status that you want to view the media from. | |
| `attachment-id` | string | false | The ID of the media attachment to download and view.<br>Use this flag multiple times to specify multiple media attachments. | |
| `all-images` | boolean | false | Set to `true` to show all images from the status. | false |
| `all-videos` | boolean | false | Set to `true` to show all videos from the status. | false |
## Bookmarks
### View your bookmarks
Prints a list of statuses that you have bookmarked.
```
enbas show --type bookmarks
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to view.<br>Here this should be `bookmarks`. | |
| `limit` | integer | false | The maximum number of bookmarks to show. | 20 |
### Add a status to your bookmarks
```
enbas add --type status --status-id 01J17FH1KD9CN6J9Q01011NE0D --to bookmarks
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to add.<br>Here this should be `status`. | |
| `status-id` | string | true | The ID of the status. | |
| `to` | string | true | The resource you want to add the status to.<br>Here this should be `bookmarks`. | |
### Remove a status from your bookmarks
```
enbas remove --type status --status-id 01J17FH1KD9CN6J9Q01011NE0D --from bookmarks
```
| flag | type | required | description | default |
|------|------|----------|-------------|---------|
| `type` | string | true | The resource you want to remove.<br>Here this should be `status`. | |
| `status-id` | string | true | The ID of the status. | |
| `from` | string | true | The resource you want to remove the status to.<br>Here this should be `bookmarks`. | |
## Notifications
_Not yet supported_

View file

@ -1,24 +0,0 @@
# Tips and Tricks
## The time duration value
The time duration value is a custom [flag value](https://pkg.go.dev/flag#Value) that converts a string input into a duration of time.
A typical string input would be in the form of something like `"3 days, 12 hours and 39 minutes"`.
The value can convert units in days, hours, minutes and seconds.
To ensure that your string input is converted correctly there are simple rules to follow.
- The input must be wrapped in quotes.
- Use `day` or `days` to convert the number of days.
- Use `hour` or `hours` to convert the number of hours.
- Use `minute` or `minutes` to convert the number of minutes.
- Use `second` or `seconds` to convert the number of seconds.
- There must be at least one space between the number and the unit of time.<br>
E.g. `"7 days"` is valid, but `"7days"` is invalid.
### Example valid string inputs
- `"3 days"`
- `"6 hours, 45 minutes and 1 second"`
- `"1 day, 15 hours 31 minutes and 12 seconds"`
- `"(7 days) (1 hour) (21 minutes) (35 seconds)"`

8
go.mod
View file

@ -1,5 +1,9 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
module codeflow.dananglin.me.uk/apollo/enbas module codeflow.dananglin.me.uk/apollo/enbas
go 1.23.0 go 1.22.0
require golang.org/x/net v0.28.0 require golang.org/x/net v0.21.0

4
go.sum
View file

@ -1,2 +1,2 @@
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=

3
go.sum.license Normal file
View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
SPDX-License-Identifier: CC0-1.0

View file

@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package client package client
import ( import (
@ -9,25 +13,13 @@ import (
"codeflow.dananglin.me.uk/apollo/enbas/internal/model" "codeflow.dananglin.me.uk/apollo/enbas/internal/model"
) )
const (
baseAccountsPath = "/api/v1/accounts"
baseFollowRequestsPath = "/api/v1/follow_requests"
)
func (g *Client) VerifyCredentials() (model.Account, error) { func (g *Client) VerifyCredentials() (model.Account, error) {
url := g.Authentication.Instance + baseAccountsPath + "/verify_credentials" path := "/api/v1/accounts/verify_credentials"
url := g.Authentication.Instance + path
var account model.Account var account model.Account
params := requestParameters{ if err := g.sendRequest(http.MethodGet, url, nil, &account); err != nil {
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &account,
}
if err := g.sendRequest(params); err != nil {
return model.Account{}, fmt.Errorf("received an error after sending the request to verify the credentials: %w", err) return model.Account{}, fmt.Errorf("received an error after sending the request to verify the credentials: %w", err)
} }
@ -35,53 +27,33 @@ func (g *Client) VerifyCredentials() (model.Account, error) {
} }
func (g *Client) GetAccount(accountURI string) (model.Account, error) { func (g *Client) GetAccount(accountURI string) (model.Account, error) {
url := g.Authentication.Instance + baseAccountsPath + "/lookup?acct=" + accountURI path := "/api/v1/accounts/lookup?acct=" + accountURI
url := g.Authentication.Instance + path
var account model.Account var account model.Account
params := requestParameters{ if err := g.sendRequest(http.MethodGet, url, nil, &account); err != nil {
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &account,
}
if err := g.sendRequest(params); err != nil {
return model.Account{}, fmt.Errorf("received an error after sending the request to get the account information: %w", err) return model.Account{}, fmt.Errorf("received an error after sending the request to get the account information: %w", err)
} }
return account, nil return account, nil
} }
func (g *Client) GetAccountRelationship(accountID string) (*model.AccountRelationship, error) { func (g *Client) GetAccountRelationship(accountID string) (model.AccountRelationship, error) {
url := g.Authentication.Instance + baseAccountsPath + "/relationships?id=" + accountID path := "/api/v1/accounts/relationships?id=" + accountID
url := g.Authentication.Instance + path
var relationships []model.AccountRelationship var relationships []model.AccountRelationship
params := requestParameters{ if err := g.sendRequest(http.MethodGet, url, nil, &relationships); err != nil {
httpMethod: http.MethodGet, return model.AccountRelationship{}, fmt.Errorf("received an error after sending the request to get the account relationship: %w", err)
url: url,
requestBody: nil,
contentType: "",
output: &relationships,
}
if err := g.sendRequest(params); err != nil {
return nil, fmt.Errorf(
"received an error after sending the request to get the account relationship: %w",
err,
)
} }
if len(relationships) != 1 { if len(relationships) != 1 {
return nil, fmt.Errorf( return model.AccountRelationship{}, fmt.Errorf("unexpected number of account relationships returned: want 1, got %d", len(relationships))
"unexpected number of account relationships returned: want 1, got %d",
len(relationships),
)
} }
return &relationships[0], nil return relationships[0], nil
} }
type FollowAccountForm struct { type FollowAccountForm struct {
@ -97,17 +69,9 @@ func (g *Client) FollowAccount(form FollowAccountForm) error {
} }
requestBody := bytes.NewBuffer(data) requestBody := bytes.NewBuffer(data)
url := g.Authentication.Instance + baseAccountsPath + "/" + form.AccountID + "/follow" url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/follow", form.AccountID)
params := requestParameters{ if err := g.sendRequest(http.MethodPost, url, requestBody, nil); err != nil {
httpMethod: http.MethodPost,
url: url,
requestBody: requestBody,
contentType: applicationJSON,
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf("received an error after sending the follow request: %w", err) return fmt.Errorf("received an error after sending the follow request: %w", err)
} }
@ -115,17 +79,9 @@ func (g *Client) FollowAccount(form FollowAccountForm) error {
} }
func (g *Client) UnfollowAccount(accountID string) error { func (g *Client) UnfollowAccount(accountID string) error {
url := g.Authentication.Instance + baseAccountsPath + "/" + accountID + "/unfollow" url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/unfollow", accountID)
params := requestParameters{ if err := g.sendRequest(http.MethodPost, url, nil, nil); err != nil {
httpMethod: http.MethodPost,
url: url,
requestBody: nil,
contentType: "",
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf("received an error after sending the request to unfollow the account: %w", err) return fmt.Errorf("received an error after sending the request to unfollow the account: %w", err)
} }
@ -133,19 +89,11 @@ func (g *Client) UnfollowAccount(accountID string) error {
} }
func (g *Client) GetFollowers(accountID string, limit int) (model.AccountList, error) { func (g *Client) GetFollowers(accountID string, limit int) (model.AccountList, error) {
url := g.Authentication.Instance + fmt.Sprintf("%s/%s/followers?limit=%d", baseAccountsPath, accountID, limit) url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/followers?limit=%d", accountID, limit)
accounts := make([]model.Account, limit) accounts := make([]model.Account, limit)
params := requestParameters{ if err := g.sendRequest(http.MethodGet, url, nil, &accounts); err != nil {
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &accounts,
}
if err := g.sendRequest(params); err != nil {
return model.AccountList{}, fmt.Errorf("received an error after sending the request to get the list of followers: %w", err) return model.AccountList{}, fmt.Errorf("received an error after sending the request to get the list of followers: %w", err)
} }
@ -158,19 +106,11 @@ func (g *Client) GetFollowers(accountID string, limit int) (model.AccountList, e
} }
func (g *Client) GetFollowing(accountID string, limit int) (model.AccountList, error) { func (g *Client) GetFollowing(accountID string, limit int) (model.AccountList, error) {
url := g.Authentication.Instance + fmt.Sprintf("%s/%s/following?limit=%d", baseAccountsPath, accountID, limit) url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/following?limit=%d", accountID, limit)
accounts := make([]model.Account, limit) accounts := make([]model.Account, limit)
params := requestParameters{ if err := g.sendRequest(http.MethodGet, url, nil, &accounts); err != nil {
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &accounts,
}
if err := g.sendRequest(params); err != nil {
return model.AccountList{}, fmt.Errorf("received an error after sending the request to get the list of followed accounts: %w", err) return model.AccountList{}, fmt.Errorf("received an error after sending the request to get the list of followed accounts: %w", err)
} }
@ -183,17 +123,9 @@ func (g *Client) GetFollowing(accountID string, limit int) (model.AccountList, e
} }
func (g *Client) BlockAccount(accountID string) error { func (g *Client) BlockAccount(accountID string) error {
url := g.Authentication.Instance + baseAccountsPath + "/" + accountID + "/block" url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/block", accountID)
params := requestParameters{ if err := g.sendRequest(http.MethodPost, url, nil, nil); err != nil {
httpMethod: http.MethodPost,
url: url,
requestBody: nil,
contentType: "",
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf("received an error after sending the request to block the account: %w", err) return fmt.Errorf("received an error after sending the request to block the account: %w", err)
} }
@ -201,17 +133,9 @@ func (g *Client) BlockAccount(accountID string) error {
} }
func (g *Client) UnblockAccount(accountID string) error { func (g *Client) UnblockAccount(accountID string) error {
url := g.Authentication.Instance + baseAccountsPath + "/" + accountID + "/unblock" url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/unblock", accountID)
params := requestParameters{ if err := g.sendRequest(http.MethodPost, url, nil, nil); err != nil {
httpMethod: http.MethodPost,
url: url,
requestBody: nil,
contentType: "",
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf("received an error after sending the request to unblock the account: %w", err) return fmt.Errorf("received an error after sending the request to unblock the account: %w", err)
} }
@ -221,17 +145,9 @@ func (g *Client) UnblockAccount(accountID string) error {
func (g *Client) GetBlockedAccounts(limit int) (model.AccountList, error) { func (g *Client) GetBlockedAccounts(limit int) (model.AccountList, error) {
url := g.Authentication.Instance + fmt.Sprintf("/api/v1/blocks?limit=%d", limit) url := g.Authentication.Instance + fmt.Sprintf("/api/v1/blocks?limit=%d", limit)
var accounts []model.Account accounts := make([]model.Account, limit)
params := requestParameters{ if err := g.sendRequest(http.MethodGet, url, nil, &accounts); err != nil {
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &accounts,
}
if err := g.sendRequest(params); err != nil {
return model.AccountList{}, fmt.Errorf("received an error after sending the request to get the list of blocked accounts: %w", err) return model.AccountList{}, fmt.Errorf("received an error after sending the request to get the list of blocked accounts: %w", err)
} }
@ -256,197 +172,11 @@ func (g *Client) SetPrivateNote(accountID, note string) error {
} }
requestBody := bytes.NewBuffer(data) requestBody := bytes.NewBuffer(data)
url := g.Authentication.Instance + baseAccountsPath + "/" + accountID + "/note" url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/note", accountID)
params := requestParameters{ if err := g.sendRequest(http.MethodPost, url, requestBody, nil); err != nil {
httpMethod: http.MethodPost,
url: url,
requestBody: requestBody,
contentType: applicationJSON,
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf("received an error after sending the request to set the private note: %w", err) return fmt.Errorf("received an error after sending the request to set the private note: %w", err)
} }
return nil return nil
} }
func (g *Client) GetFollowRequests(limit int) (model.AccountList, error) {
url := g.Authentication.Instance + fmt.Sprintf("%s?limit=%d", baseFollowRequestsPath, limit)
var accounts []model.Account
params := requestParameters{
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &accounts,
}
if err := g.sendRequest(params); err != nil {
return model.AccountList{}, fmt.Errorf("received an error after sending the request to get the list of follow requests: %w", err)
}
requests := model.AccountList{
Type: model.AccountListFollowRequests,
Accounts: accounts,
}
return requests, nil
}
func (g *Client) AcceptFollowRequest(accountID string) error {
url := g.Authentication.Instance + baseFollowRequestsPath + "/" + accountID + "/authorize"
params := requestParameters{
httpMethod: http.MethodPost,
url: url,
requestBody: nil,
contentType: "",
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf("received an error after sending the request to accept the follow request: %w", err)
}
return nil
}
func (g *Client) RejectFollowRequest(accountID string) error {
url := g.Authentication.Instance + baseFollowRequestsPath + "/" + accountID + "/reject"
params := requestParameters{
httpMethod: http.MethodPost,
url: url,
requestBody: nil,
contentType: "",
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf("received an error after sending the request to reject the follow request: %w", err)
}
return nil
}
func (g *Client) GetMutedAccounts(limit int) (model.AccountList, error) {
url := g.Authentication.Instance + fmt.Sprintf("/api/v1/mutes?limit=%d", limit)
var accounts []model.Account
params := requestParameters{
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &accounts,
}
if err := g.sendRequest(params); err != nil {
return model.AccountList{}, fmt.Errorf("received an error after sending the request to get the list of muted accounts: %w", err)
}
muted := model.AccountList{
Type: model.AccountListMuted,
Accounts: accounts,
}
return muted, nil
}
type MuteAccountForm struct {
Notifications bool `json:"notifications"`
Duration int `json:"duration"`
}
func (g *Client) MuteAccount(accountID string, form MuteAccountForm) error {
data, err := json.Marshal(form)
if err != nil {
return fmt.Errorf("unable to marshal the form: %w", err)
}
requestBody := bytes.NewBuffer(data)
url := g.Authentication.Instance + baseAccountsPath + "/" + accountID + "/mute"
params := requestParameters{
httpMethod: http.MethodPost,
url: url,
requestBody: requestBody,
contentType: applicationJSON,
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf("received an error after sending the request to mute the account: %w", err)
}
return nil
}
func (g *Client) UnmuteAccount(accountID string) error {
url := g.Authentication.Instance + baseAccountsPath + "/" + accountID + "/unmute"
params := requestParameters{
httpMethod: http.MethodPost,
url: url,
requestBody: nil,
contentType: "",
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf("received an error after sending the request to unmute the account: %w", err)
}
return nil
}
type GetAccountStatusesForm struct {
AccountID string
Limit int
ExcludeReplies bool
ExcludeReblogs bool
Pinned bool
OnlyMedia bool
OnlyPublic bool
}
func (g *Client) GetAccountStatuses(form GetAccountStatusesForm) (*model.StatusList, error) {
path := baseAccountsPath + "/" + form.AccountID + "/statuses"
query := fmt.Sprintf(
"?limit=%d&exclude_replies=%t&exclude_reblogs=%t&pinned=%t&only_media=%t&only_public=%t",
form.Limit,
form.ExcludeReplies,
form.ExcludeReblogs,
form.Pinned,
form.OnlyMedia,
form.OnlyPublic,
)
url := g.Authentication.Instance + path + query
var statuses []model.Status
params := requestParameters{
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &statuses,
}
if err := g.sendRequest(params); err != nil {
return nil, fmt.Errorf("received an error after sending the request to get the account's statuses: %w", err)
}
statusList := model.StatusList{
Name: "STATUSES:",
Statuses: statuses,
}
return &statusList, nil
}

View file

@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package client package client
import ( import (
@ -7,18 +11,12 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"time" "time"
"codeflow.dananglin.me.uk/apollo/enbas/internal"
"codeflow.dananglin.me.uk/apollo/enbas/internal/config" "codeflow.dananglin.me.uk/apollo/enbas/internal/config"
) )
const (
applicationJSON string = "application/json; charset=utf-8"
redirectURI string = "urn:ietf:wg:oauth:2.0:oob"
userAgent string = "Enbas/0.2.0"
)
type Client struct { type Client struct {
Authentication config.Credentials Authentication config.Credentials
HTTPClient http.Client HTTPClient http.Client
@ -26,8 +24,8 @@ type Client struct {
Timeout time.Duration Timeout time.Duration
} }
func NewClientFromFile(path string) (*Client, error) { func NewClientFromConfig(configDir string) (*Client, error) {
config, err := config.NewCredentialsConfigFromFile(path) config, err := config.NewCredentialsConfigFromFile(configDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to get the authentication configuration: %w", err) return nil, fmt.Errorf("unable to get the authentication configuration: %w", err)
} }
@ -43,7 +41,7 @@ func NewClient(authentication config.Credentials) *Client {
gtsClient := Client{ gtsClient := Client{
Authentication: authentication, Authentication: authentication,
HTTPClient: httpClient, HTTPClient: httpClient,
UserAgent: userAgent, UserAgent: internal.UserAgent,
Timeout: 5 * time.Second, Timeout: 5 * time.Second,
} }
@ -52,7 +50,7 @@ func NewClient(authentication config.Credentials) *Client {
func (g *Client) AuthCodeURL() string { func (g *Client) AuthCodeURL() string {
format := "%s/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code" format := "%s/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code"
escapedRedirectURI := url.QueryEscape(redirectURI) escapedRedirectURI := url.QueryEscape(internal.RedirectUri)
return fmt.Sprintf( return fmt.Sprintf(
format, format,
@ -62,64 +60,17 @@ func (g *Client) AuthCodeURL() string {
) )
} }
func (g *Client) DownloadMedia(url, path string) error { func (g *Client) sendRequest(method string, url string, requestBody io.Reader, object any) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("unable to create the HTTP request: %w", err)
}
request.Header.Set("User-Agent", g.UserAgent)
response, err := g.HTTPClient.Do(request)
if err != nil {
return fmt.Errorf("received an error after attempting the download: %w", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return BadStatusCodeError{
statusCode: response.StatusCode,
}
}
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("unable to create %s: %w", path, err)
}
defer file.Close()
if _, err = io.Copy(file, response.Body); err != nil {
return fmt.Errorf("unable to save the download to %s: %w", path, err)
}
return nil
}
type requestParameters struct {
httpMethod string
url string
contentType string
requestBody io.Reader
output any
}
func (g *Client) sendRequest(params requestParameters) error {
ctx, cancel := context.WithTimeout(context.Background(), g.Timeout) ctx, cancel := context.WithTimeout(context.Background(), g.Timeout)
defer cancel() defer cancel()
request, err := http.NewRequestWithContext(ctx, params.httpMethod, params.url, params.requestBody) request, err := http.NewRequestWithContext(ctx, method, url, requestBody)
if err != nil { if err != nil {
return fmt.Errorf("unable to create the HTTP request: %w", err) return fmt.Errorf("unable to create the HTTP request, %w", err)
} }
if params.contentType != "" { request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("Content-Type", params.contentType) request.Header.Set("Accept", "application/json; charset=utf-8")
}
request.Header.Set("Accept", applicationJSON)
request.Header.Set("User-Agent", g.UserAgent) request.Header.Set("User-Agent", g.UserAgent)
if len(g.Authentication.AccessToken) > 0 { if len(g.Authentication.AccessToken) > 0 {
@ -134,32 +85,17 @@ func (g *Client) sendRequest(params requestParameters) error {
defer response.Body.Close() defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusBadRequest { if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusBadRequest {
message := struct { return fmt.Errorf(
Error string `json:"error"` "did not receive an OK response from the GoToSocial server: got %d",
}{ response.StatusCode,
Error: "", )
} }
if err := json.NewDecoder(response.Body).Decode(&message); err != nil { if object == nil {
return ResponseError{
StatusCode: response.StatusCode,
Message: "",
MessageDecodeErr: err,
}
}
return ResponseError{
StatusCode: response.StatusCode,
Message: message.Error,
MessageDecodeErr: nil,
}
}
if params.output == nil {
return nil return nil
} }
if err := json.NewDecoder(response.Body).Decode(params.output); err != nil { if err := json.NewDecoder(response.Body).Decode(object); err != nil {
return fmt.Errorf( return fmt.Errorf(
"unable to decode the response from the GoToSocial server: %w", "unable to decode the response from the GoToSocial server: %w",
err, err,

View file

@ -1,51 +0,0 @@
package client
import "fmt"
type ResponseError struct {
StatusCode int
Message string
MessageDecodeErr error
}
func (e ResponseError) Error() string {
if e.MessageDecodeErr != nil {
return fmt.Sprintf(
"received HTTP code %d from the instance but was unable to decode the error message: %v",
e.StatusCode,
e.MessageDecodeErr,
)
}
if e.Message == "" {
return fmt.Sprintf(
"received HTTP code %d from the instance but no error message was provided",
e.StatusCode,
)
}
return fmt.Sprintf(
"message received from the instance: (%d) %q",
e.StatusCode,
e.Message,
)
}
type BadStatusCodeError struct {
statusCode int
}
func (e BadStatusCodeError) Error() string {
return fmt.Sprintf(
"did not receive an OK response from the GoToSocial server: got %d",
e.statusCode,
)
}
type Error struct {
message string
}
func (e Error) Error() string {
return e.message
}

View file

@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package client package client
import ( import (
@ -13,15 +17,7 @@ func (g *Client) GetInstance() (model.InstanceV2, error) {
var instance model.InstanceV2 var instance model.InstanceV2
params := requestParameters{ if err := g.sendRequest(http.MethodGet, url, nil, &instance); err != nil {
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &instance,
}
if err := g.sendRequest(params); err != nil {
return model.InstanceV2{}, fmt.Errorf("received an error after sending the request to get the instance details: %w", err) return model.InstanceV2{}, fmt.Errorf("received an error after sending the request to get the instance details: %w", err)
} }

View file

@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package client package client
import ( import (
@ -10,23 +14,15 @@ import (
) )
const ( const (
baseListPath string = "/api/v1/lists" listPath string = "/api/v1/lists"
) )
func (g *Client) GetAllLists() ([]model.List, error) { func (g *Client) GetAllLists() (model.Lists, error) {
url := g.Authentication.Instance + baseListPath url := g.Authentication.Instance + listPath
var lists []model.List var lists []model.List
params := requestParameters{ if err := g.sendRequest(http.MethodGet, url, nil, &lists); err != nil {
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &lists,
}
if err := g.sendRequest(params); err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"received an error after sending the request to get the list of lists: %w", "received an error after sending the request to get the list of lists: %w",
err, err,
@ -37,19 +33,11 @@ func (g *Client) GetAllLists() ([]model.List, error) {
} }
func (g *Client) GetList(listID string) (model.List, error) { func (g *Client) GetList(listID string) (model.List, error) {
url := g.Authentication.Instance + baseListPath + "/" + listID url := g.Authentication.Instance + listPath + "/" + listID
var list model.List var list model.List
params := requestParameters{ if err := g.sendRequest(http.MethodGet, url, nil, &list); err != nil {
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &list,
}
if err := g.sendRequest(params); err != nil {
return model.List{}, fmt.Errorf( return model.List{}, fmt.Errorf(
"received an error after sending the request to get the list: %w", "received an error after sending the request to get the list: %w",
err, err,
@ -71,19 +59,11 @@ func (g *Client) CreateList(form CreateListForm) (model.List, error) {
} }
requestBody := bytes.NewBuffer(data) requestBody := bytes.NewBuffer(data)
url := g.Authentication.Instance + baseListPath url := g.Authentication.Instance + "/api/v1/lists"
var list model.List var list model.List
params := requestParameters{ if err := g.sendRequest(http.MethodPost, url, requestBody, &list); err != nil {
httpMethod: http.MethodPost,
url: url,
requestBody: requestBody,
contentType: applicationJSON,
output: &list,
}
if err := g.sendRequest(params); err != nil {
return model.List{}, fmt.Errorf( return model.List{}, fmt.Errorf(
"received an error after sending the request to create the list: %w", "received an error after sending the request to create the list: %w",
err, err,
@ -108,19 +88,11 @@ func (g *Client) UpdateList(listToUpdate model.List) (model.List, error) {
} }
requestBody := bytes.NewBuffer(data) requestBody := bytes.NewBuffer(data)
url := g.Authentication.Instance + baseListPath + "/" + listToUpdate.ID url := g.Authentication.Instance + listPath + "/" + listToUpdate.ID
var updatedList model.List var updatedList model.List
params := requestParameters{ if err := g.sendRequest(http.MethodPut, url, requestBody, &updatedList); err != nil {
httpMethod: http.MethodPut,
url: url,
requestBody: requestBody,
contentType: applicationJSON,
output: &updatedList,
}
if err := g.sendRequest(params); err != nil {
return model.List{}, fmt.Errorf( return model.List{}, fmt.Errorf(
"received an error after sending the request to update the list: %w", "received an error after sending the request to update the list: %w",
err, err,
@ -131,24 +103,9 @@ func (g *Client) UpdateList(listToUpdate model.List) (model.List, error) {
} }
func (g *Client) DeleteList(listID string) error { func (g *Client) DeleteList(listID string) error {
url := g.Authentication.Instance + baseListPath + "/" + listID url := g.Authentication.Instance + "/api/v1/lists/" + listID
params := requestParameters{ return g.sendRequest(http.MethodDelete, url, nil, nil)
httpMethod: http.MethodDelete,
url: url,
requestBody: nil,
contentType: "",
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf(
"received an error after sending the request to delete the list: %w",
err,
)
}
return nil
} }
func (g *Client) AddAccountsToList(listID string, accountIDs []string) error { func (g *Client) AddAccountsToList(listID string, accountIDs []string) error {
@ -164,17 +121,9 @@ func (g *Client) AddAccountsToList(listID string, accountIDs []string) error {
} }
requestBody := bytes.NewBuffer(data) requestBody := bytes.NewBuffer(data)
url := g.Authentication.Instance + baseListPath + "/" + listID + "/accounts" url := g.Authentication.Instance + listPath + "/" + listID + "/accounts"
params := requestParameters{ if err := g.sendRequest(http.MethodPost, url, requestBody, nil); err != nil {
httpMethod: http.MethodPost,
url: url,
requestBody: requestBody,
contentType: applicationJSON,
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf( return fmt.Errorf(
"received an error after sending the request to add the accounts to the list: %w", "received an error after sending the request to add the accounts to the list: %w",
err, err,
@ -197,17 +146,9 @@ func (g *Client) RemoveAccountsFromList(listID string, accountIDs []string) erro
} }
requestBody := bytes.NewBuffer(data) requestBody := bytes.NewBuffer(data)
url := g.Authentication.Instance + baseListPath + "/" + listID + "/accounts" url := g.Authentication.Instance + listPath + "/" + listID + "/accounts"
params := requestParameters{ if err := g.sendRequest(http.MethodDelete, url, requestBody, nil); err != nil {
httpMethod: http.MethodDelete,
url: url,
requestBody: requestBody,
contentType: applicationJSON,
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf( return fmt.Errorf(
"received an error after sending the request to remove the accounts from the list: %w", "received an error after sending the request to remove the accounts from the list: %w",
err, err,
@ -218,20 +159,12 @@ func (g *Client) RemoveAccountsFromList(listID string, accountIDs []string) erro
} }
func (g *Client) GetAccountsFromList(listID string, limit int) ([]model.Account, error) { func (g *Client) GetAccountsFromList(listID string, limit int) ([]model.Account, error) {
path := fmt.Sprintf("%s/%s/accounts?limit=%d", baseListPath, listID, limit) path := fmt.Sprintf("%s/%s/accounts?limit=%d", listPath, listID, limit)
url := g.Authentication.Instance + path url := g.Authentication.Instance + path
var accounts []model.Account var accounts []model.Account
params := requestParameters{ if err := g.sendRequest(http.MethodGet, url, nil, &accounts); err != nil {
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &accounts,
}
if err := g.sendRequest(params); err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"received an error after sending the request to get the accounts from the list: %w", "received an error after sending the request to get the accounts from the list: %w",
err, err,

View file

@ -1,159 +0,0 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model"
)
const (
baseMediaPath string = "/api/v1/media"
)
func (g *Client) GetMediaAttachment(mediaAttachmentID string) (model.Attachment, error) {
url := g.Authentication.Instance + baseMediaPath + "/" + mediaAttachmentID
var attachment model.Attachment
params := requestParameters{
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &attachment,
}
if err := g.sendRequest(params); err != nil {
return model.Attachment{}, fmt.Errorf("received an error after sending the request to get the media attachment: %w", err)
}
return attachment, nil
}
func (g *Client) CreateMediaAttachment(path, description, focus string) (model.Attachment, error) {
file, err := os.Open(path)
if err != nil {
return model.Attachment{}, fmt.Errorf("unable to open the file: %w", err)
}
defer file.Close()
// create the request body using a writer from the multipart package
requestBody := bytes.Buffer{}
requestBodyWriter := multipart.NewWriter(&requestBody)
filename := filepath.Base(path)
part, err := requestBodyWriter.CreateFormFile("file", filename)
if err != nil {
return model.Attachment{}, fmt.Errorf("unable to create the new part: %w", err)
}
if _, err := io.Copy(part, file); err != nil {
return model.Attachment{}, fmt.Errorf("unable to copy the file contents to the form: %w", err)
}
// add the description
if description != "" {
descriptionFormFieldWriter, err := requestBodyWriter.CreateFormField("description")
if err != nil {
return model.Attachment{}, fmt.Errorf(
"unable to create the writer for the 'description' form field: %w",
err,
)
}
if _, err := io.WriteString(descriptionFormFieldWriter, description); err != nil {
return model.Attachment{}, fmt.Errorf(
"unable to write the description to the form: %w",
err,
)
}
}
// add the focus values
if focus != "" {
focusFormFieldWriter, err := requestBodyWriter.CreateFormField("focus")
if err != nil {
return model.Attachment{}, fmt.Errorf(
"unable to create the writer for the 'focus' form field: %w",
err,
)
}
if _, err := io.WriteString(focusFormFieldWriter, focus); err != nil {
return model.Attachment{}, fmt.Errorf(
"unable to write the focus values to the form: %w",
err,
)
}
}
if err := requestBodyWriter.Close(); err != nil {
return model.Attachment{}, fmt.Errorf("unable to close the writer: %w", err)
}
url := g.Authentication.Instance + baseMediaPath
var attachment model.Attachment
params := requestParameters{
httpMethod: http.MethodPost,
url: url,
requestBody: &requestBody,
contentType: requestBodyWriter.FormDataContentType(),
output: &attachment,
}
if err := g.sendRequest(params); err != nil {
return model.Attachment{}, fmt.Errorf(
"received an error after sending the request to create the media attachment: %w",
err,
)
}
return attachment, nil
}
func (g *Client) UpdateMediaAttachment(mediaAttachmentID, description, focus string) (model.Attachment, error) {
form := struct {
Description string `json:"description"`
Focus string `json:"focus"`
}{
Description: description,
Focus: focus,
}
data, err := json.Marshal(form)
if err != nil {
return model.Attachment{}, fmt.Errorf("unable to marshal the form: %w", err)
}
requestBody := bytes.NewBuffer(data)
url := g.Authentication.Instance + baseMediaPath + "/" + mediaAttachmentID
var updatedMediaAttachment model.Attachment
params := requestParameters{
httpMethod: http.MethodPut,
url: url,
requestBody: requestBody,
contentType: applicationJSON,
output: &updatedMediaAttachment,
}
if err := g.sendRequest(params); err != nil {
return model.Attachment{}, fmt.Errorf(
"received an error after sending the request to update the media attachment: %w",
err,
)
}
return updatedMediaAttachment, nil
}

View file

@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package client
import (
"fmt"
"net/http"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model"
)
func (g *Client) GetNotifications(limit int) ([]model.Notification, error) {
path := fmt.Sprintf("/api/v1/notifications?limit=%d", limit)
url := g.Authentication.Instance + path
var notifications []model.Notification
if err := g.sendRequest(http.MethodGet, url, nil, &notifications); err != nil {
return nil, fmt.Errorf("received an error after sending the request to get your notifications: %w", err)
}
return notifications, nil
}
func (g *Client) GetNotification() error {
return nil
}
func (g *Client) DeleteAllNotifications() error {
return nil
}

View file

@ -1,65 +0,0 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
const (
pollPath string = "/api/v1/polls"
)
// func (g *Client) GetPoll(pollID string) (model.Poll, error) {
// url := g.Authentication.Instance + pollPath + "/" + pollID
//
// var poll model.Poll
//
// params := requestParameters{
// httpMethod: http.MethodGet,
// url: url,
// requestBody: nil,
// contentType: "",
// output: &poll,
// }
//
// if err := g.sendRequest(params); err != nil {
// return model.Poll{}, fmt.Errorf(
// "received an error after sending the request to get the poll: %w",
// err,
// )
// }
//
// return poll, nil
// }
func (g *Client) VoteInPoll(pollID string, choices []int) error {
form := struct {
Choices []int `json:"choices"`
}{
Choices: choices,
}
data, err := json.Marshal(form)
if err != nil {
return fmt.Errorf("unable to encode the JSON form: %w", err)
}
requestBody := bytes.NewBuffer(data)
url := g.Authentication.Instance + pollPath + "/" + pollID + "/votes"
params := requestParameters{
httpMethod: http.MethodPost,
url: url,
requestBody: requestBody,
contentType: applicationJSON,
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf("received an error after sending the request to vote in the poll: %w", err)
}
return nil
}

View file

@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package client package client
import ( import (
@ -7,25 +11,14 @@ import (
"codeflow.dananglin.me.uk/apollo/enbas/internal/model" "codeflow.dananglin.me.uk/apollo/enbas/internal/model"
) )
func (g *Client) GetUserPreferences() (*model.Preferences, error) { func (g *Client) GetUserPreferences() (model.Preferences, error) {
url := g.Authentication.Instance + "/api/v1/preferences" url := g.Authentication.Instance + "/api/v1/preferences"
var preferences model.Preferences var preferences model.Preferences
params := requestParameters{ if err := g.sendRequest(http.MethodGet, url, nil, &preferences); err != nil {
httpMethod: http.MethodGet, return model.Preferences{}, fmt.Errorf("received an error after sending the request to get the user preferences: %w", err)
url: url,
requestBody: nil,
contentType: "",
output: &preferences,
} }
if err := g.sendRequest(params); err != nil { return preferences, nil
return nil, fmt.Errorf(
"received an error after sending the request to get the user preferences: %w",
err,
)
}
return &preferences, nil
} }

View file

@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package client package client
import ( import (
@ -6,7 +10,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"codeflow.dananglin.me.uk/apollo/enbas/internal/info" "codeflow.dananglin.me.uk/apollo/enbas/internal"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model" "codeflow.dananglin.me.uk/apollo/enbas/internal/model"
) )
@ -18,14 +22,14 @@ type registerRequest struct {
} }
func (g *Client) Register() error { func (g *Client) Register() error {
registerParams := registerRequest{ params := registerRequest{
ClientName: info.ApplicationName, ClientName: internal.ApplicationName,
RedirectUris: redirectURI, RedirectUris: internal.RedirectUri,
Scopes: "read write", Scopes: "read write",
Website: info.ApplicationWebsite, Website: internal.ApplicationWebsite,
} }
data, err := json.Marshal(registerParams) data, err := json.Marshal(params)
if err != nil { if err != nil {
return fmt.Errorf("unable to marshal the request body: %w", err) return fmt.Errorf("unable to marshal the request body: %w", err)
} }
@ -35,15 +39,7 @@ func (g *Client) Register() error {
var app model.Application var app model.Application
requestParams := requestParameters{ if err := g.sendRequest(http.MethodPost, url, requestBody, &app); err != nil {
httpMethod: http.MethodPost,
url: url,
requestBody: requestBody,
contentType: applicationJSON,
output: &app,
}
if err := g.sendRequest(requestParams); err != nil {
return fmt.Errorf("received an error after sending the registration request: %w", err) return fmt.Errorf("received an error after sending the registration request: %w", err)
} }

View file

@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package client package client
import ( import (
@ -9,25 +13,13 @@ import (
"codeflow.dananglin.me.uk/apollo/enbas/internal/model" "codeflow.dananglin.me.uk/apollo/enbas/internal/model"
) )
const (
baseStatusesPath string = "/api/v1/statuses"
)
func (g *Client) GetStatus(statusID string) (model.Status, error) { func (g *Client) GetStatus(statusID string) (model.Status, error) {
path := baseStatusesPath + "/" + statusID path := "/api/v1/statuses/" + statusID
url := g.Authentication.Instance + path url := g.Authentication.Instance + path
var status model.Status var status model.Status
params := requestParameters{ if err := g.sendRequest(http.MethodGet, url, nil, &status); err != nil {
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &status,
}
if err := g.sendRequest(params); err != nil {
return model.Status{}, fmt.Errorf( return model.Status{}, fmt.Errorf(
"received an error after sending the request to get the status information: %w", "received an error after sending the request to get the status information: %w",
err, err,
@ -39,7 +31,6 @@ func (g *Client) GetStatus(statusID string) (model.Status, error) {
type CreateStatusForm struct { type CreateStatusForm struct {
Content string `json:"status"` Content string `json:"status"`
InReplyTo string `json:"in_reply_to_id"`
Language string `json:"language"` Language string `json:"language"`
SpoilerText string `json:"spoiler_text"` SpoilerText string `json:"spoiler_text"`
Boostable bool `json:"boostable"` Boostable bool `json:"boostable"`
@ -47,17 +38,8 @@ type CreateStatusForm struct {
Likeable bool `json:"likeable"` Likeable bool `json:"likeable"`
Replyable bool `json:"replyable"` Replyable bool `json:"replyable"`
Sensitive bool `json:"sensitive"` Sensitive bool `json:"sensitive"`
Poll *CreateStatusPollForm `json:"poll,omitempty"`
ContentType model.StatusContentType `json:"content_type"` ContentType model.StatusContentType `json:"content_type"`
Visibility model.StatusVisibility `json:"visibility"` Visibility model.StatusVisibility `json:"visibility"`
AttachmentIDs []string `json:"media_ids,omitempty"`
}
type CreateStatusPollForm struct {
Options []string `json:"options"`
ExpiresIn int `json:"expires_in"`
Multiple bool `json:"multiple"`
HideTotals bool `json:"hide_totals"`
} }
func (g *Client) CreateStatus(form CreateStatusForm) (model.Status, error) { func (g *Client) CreateStatus(form CreateStatusForm) (model.Status, error) {
@ -67,19 +49,11 @@ func (g *Client) CreateStatus(form CreateStatusForm) (model.Status, error) {
} }
requestBody := bytes.NewBuffer(data) requestBody := bytes.NewBuffer(data)
url := g.Authentication.Instance + baseStatusesPath url := g.Authentication.Instance + "/api/v1/statuses"
var status model.Status var status model.Status
params := requestParameters{ if err := g.sendRequest(http.MethodPost, url, requestBody, &status); err != nil {
httpMethod: http.MethodPost,
url: url,
requestBody: requestBody,
contentType: applicationJSON,
output: &status,
}
if err := g.sendRequest(params); err != nil {
return model.Status{}, fmt.Errorf( return model.Status{}, fmt.Errorf(
"received an error after sending the request to create the status: %w", "received an error after sending the request to create the status: %w",
err, err,
@ -94,19 +68,12 @@ func (g *Client) GetBookmarks(limit int) (model.StatusList, error) {
url := g.Authentication.Instance + path url := g.Authentication.Instance + path
bookmarks := model.StatusList{ bookmarks := model.StatusList{
Name: "Your Bookmarks", Type: model.StatusListBookMarks,
Name: "BOOKMARKS",
Statuses: nil, Statuses: nil,
} }
params := requestParameters{ if err := g.sendRequest(http.MethodGet, url, nil, &bookmarks.Statuses); err != nil {
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &bookmarks.Statuses,
}
if err := g.sendRequest(params); err != nil {
return bookmarks, fmt.Errorf( return bookmarks, fmt.Errorf(
"received an error after sending the request to get the bookmarks: %w", "received an error after sending the request to get the bookmarks: %w",
err, err,
@ -120,15 +87,7 @@ func (g *Client) AddStatusToBookmarks(statusID string) error {
path := fmt.Sprintf("/api/v1/statuses/%s/bookmark", statusID) path := fmt.Sprintf("/api/v1/statuses/%s/bookmark", statusID)
url := g.Authentication.Instance + path url := g.Authentication.Instance + path
params := requestParameters{ if err := g.sendRequest(http.MethodPost, url, nil, nil); err != nil {
httpMethod: http.MethodPost,
url: url,
requestBody: nil,
contentType: "",
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf( return fmt.Errorf(
"received an error after sending the request to add the status to the list of bookmarks: %w", "received an error after sending the request to add the status to the list of bookmarks: %w",
err, err,
@ -142,15 +101,7 @@ func (g *Client) RemoveStatusFromBookmarks(statusID string) error {
path := fmt.Sprintf("/api/v1/statuses/%s/unbookmark", statusID) path := fmt.Sprintf("/api/v1/statuses/%s/unbookmark", statusID)
url := g.Authentication.Instance + path url := g.Authentication.Instance + path
params := requestParameters{ if err := g.sendRequest(http.MethodPost, url, nil, nil); err != nil {
httpMethod: http.MethodPost,
url: url,
requestBody: nil,
contentType: "",
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf( return fmt.Errorf(
"received an error after sending the request to remove the status from the list of bookmarks: %w", "received an error after sending the request to remove the status from the list of bookmarks: %w",
err, err,
@ -159,178 +110,3 @@ func (g *Client) RemoveStatusFromBookmarks(statusID string) error {
return nil return nil
} }
func (g *Client) LikeStatus(statusID string) error {
url := g.Authentication.Instance + baseStatusesPath + "/" + statusID + "/favourite"
params := requestParameters{
httpMethod: http.MethodPost,
url: url,
requestBody: nil,
contentType: "",
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf(
"received an error after sending the request to like the status: %w",
err,
)
}
return nil
}
func (g *Client) UnlikeStatus(statusID string) error {
url := g.Authentication.Instance + baseStatusesPath + "/" + statusID + "/unfavourite"
params := requestParameters{
httpMethod: http.MethodPost,
url: url,
requestBody: nil,
contentType: "",
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf(
"received an error after sending the request to unlike the status: %w",
err,
)
}
return nil
}
func (g *Client) GetLikedStatuses(limit int, resourceName string) (model.StatusList, error) {
url := g.Authentication.Instance + fmt.Sprintf("/api/v1/favourites?limit=%d", limit)
liked := model.StatusList{
Name: "Your " + resourceName + " statuses",
Statuses: nil,
}
params := requestParameters{
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &liked.Statuses,
}
if err := g.sendRequest(params); err != nil {
return model.StatusList{}, fmt.Errorf(
"received an error after sending the request to get the list of statuses: %w",
err,
)
}
return liked, nil
}
func (g *Client) ReblogStatus(statusID string) error {
url := g.Authentication.Instance + baseStatusesPath + "/" + statusID + "/reblog"
params := requestParameters{
httpMethod: http.MethodPost,
url: url,
requestBody: nil,
contentType: "",
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf(
"received an error after sending the request to reblog the status: %w",
err,
)
}
return nil
}
func (g *Client) UnreblogStatus(statusID string) error {
url := g.Authentication.Instance + baseStatusesPath + "/" + statusID + "/unreblog"
params := requestParameters{
httpMethod: http.MethodPost,
url: url,
requestBody: nil,
contentType: "",
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf(
"received an error after sending the request to un-reblog the status: %w",
err,
)
}
return nil
}
func (g *Client) MuteStatus(statusID string) error {
url := g.Authentication.Instance + baseStatusesPath + "/" + statusID + "/mute"
params := requestParameters{
httpMethod: http.MethodPost,
url: url,
requestBody: nil,
contentType: "",
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf(
"received an error after sending the request to mute the status: %w",
err,
)
}
return nil
}
func (g *Client) UnmuteStatus(statusID string) error {
url := g.Authentication.Instance + baseStatusesPath + "/" + statusID + "/unmute"
params := requestParameters{
httpMethod: http.MethodPost,
url: url,
requestBody: nil,
contentType: "",
output: nil,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf(
"received an error after sending the request to unmute the status: %w",
err,
)
}
return nil
}
func (g *Client) DeleteStatus(statusID string) (string, error) {
url := g.Authentication.Instance + baseStatusesPath + "/" + statusID
var status model.Status
params := requestParameters{
httpMethod: http.MethodDelete,
url: url,
requestBody: nil,
contentType: "",
output: &status,
}
if err := g.sendRequest(params); err != nil {
return "", fmt.Errorf(
"received an error after sending the request to delete the status: %w",
err,
)
}
return status.Text, nil
}

View file

@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package client package client
import ( import (
@ -11,7 +15,8 @@ func (g *Client) GetHomeTimeline(limit int) (model.StatusList, error) {
path := fmt.Sprintf("/api/v1/timelines/home?limit=%d", limit) path := fmt.Sprintf("/api/v1/timelines/home?limit=%d", limit)
timeline := model.StatusList{ timeline := model.StatusList{
Name: "Timeline: Home", Type: model.StatusListTimeline,
Name: "HOME",
Statuses: nil, Statuses: nil,
} }
@ -22,7 +27,8 @@ func (g *Client) GetPublicTimeline(limit int) (model.StatusList, error) {
path := fmt.Sprintf("/api/v1/timelines/public?limit=%d", limit) path := fmt.Sprintf("/api/v1/timelines/public?limit=%d", limit)
timeline := model.StatusList{ timeline := model.StatusList{
Name: "Timeline: Public", Type: model.StatusListTimeline,
Name: "PUBLIC",
Statuses: nil, Statuses: nil,
} }
@ -33,7 +39,8 @@ func (g *Client) GetListTimeline(listID, title string, limit int) (model.StatusL
path := fmt.Sprintf("/api/v1/timelines/list/%s?limit=%d", listID, limit) path := fmt.Sprintf("/api/v1/timelines/list/%s?limit=%d", listID, limit)
timeline := model.StatusList{ timeline := model.StatusList{
Name: "Timeline: List (" + title + ")", Type: model.StatusListTimeline,
Name: "LIST (" + title + ")",
Statuses: nil, Statuses: nil,
} }
@ -44,7 +51,8 @@ func (g *Client) GetTagTimeline(tag string, limit int) (model.StatusList, error)
path := fmt.Sprintf("/api/v1/timelines/tag/%s?limit=%d", tag, limit) path := fmt.Sprintf("/api/v1/timelines/tag/%s?limit=%d", tag, limit)
timeline := model.StatusList{ timeline := model.StatusList{
Name: "Timeline: Tag (" + tag + ")", Type: model.StatusListTimeline,
Name: "TAG (" + tag + ")",
Statuses: nil, Statuses: nil,
} }
@ -56,15 +64,7 @@ func (g *Client) getTimeline(path string, timeline model.StatusList) (model.Stat
var statuses []model.Status var statuses []model.Status
params := requestParameters{ if err := g.sendRequest(http.MethodGet, url, nil, &statuses); err != nil {
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &statuses,
}
if err := g.sendRequest(params); err != nil {
return timeline, fmt.Errorf("received an error after sending the request to get the timeline: %w", err) return timeline, fmt.Errorf("received an error after sending the request to get the timeline: %w", err)
} }

View file

@ -1,14 +1,23 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package client package client
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"codeflow.dananglin.me.uk/apollo/enbas/internal"
) )
var errEmptyAccessToken = errors.New("received an empty access token")
type tokenRequest struct { type tokenRequest struct {
RedirectURI string `json:"redirect_uri"` RedirectUri string `json:"redirect_uri"`
ClientID string `json:"client_id"` ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"` ClientSecret string `json:"client_secret"`
GrantType string `json:"grant_type"` GrantType string `json:"grant_type"`
@ -23,15 +32,15 @@ type tokenResponse struct {
} }
func (g *Client) UpdateToken(code string) error { func (g *Client) UpdateToken(code string) error {
tokenReq := tokenRequest{ params := tokenRequest{
RedirectURI: redirectURI, RedirectUri: internal.RedirectUri,
ClientID: g.Authentication.ClientID, ClientID: g.Authentication.ClientID,
ClientSecret: g.Authentication.ClientSecret, ClientSecret: g.Authentication.ClientSecret,
GrantType: "authorization_code", GrantType: "authorization_code",
Code: code, Code: code,
} }
data, err := json.Marshal(tokenReq) data, err := json.Marshal(params)
if err != nil { if err != nil {
return fmt.Errorf("unable to marshal the request body: %w", err) return fmt.Errorf("unable to marshal the request body: %w", err)
} }
@ -41,20 +50,12 @@ func (g *Client) UpdateToken(code string) error {
var response tokenResponse var response tokenResponse
params := requestParameters{ if err := g.sendRequest(http.MethodPost, url, requestBody, &response); err != nil {
httpMethod: http.MethodPost,
url: url,
requestBody: requestBody,
contentType: applicationJSON,
output: &response,
}
if err := g.sendRequest(params); err != nil {
return fmt.Errorf("received an error after sending the token request: %w", err) return fmt.Errorf("received an error after sending the token request: %w", err)
} }
if response.AccessToken == "" { if response.AccessToken == "" {
return Error{"received an empty access token"} return errEmptyAccessToken
} }
g.Authentication.AccessToken = response.AccessToken g.Authentication.AccessToken = response.AccessToken

View file

@ -1,128 +0,0 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
)
const (
configFileName string = "config.json"
defaultHTTPTimeout int = 5
defaultHTTPMediaTimeout int = 30
defaultLineWrapMaxWidth int = 80
)
type Config struct {
CredentialsFile string `json:"credentialsFile"`
CacheDirectory string `json:"cacheDirectory"`
LineWrapMaxWidth int `json:"lineWrapMaxWidth"`
HTTP HTTPConfig `json:"http"`
Integrations Integrations `json:"integrations"`
}
type HTTPConfig struct {
Timeout int `json:"timeout"`
MediaTimeout int `json:"mediaTimeout"`
}
type Integrations struct {
Browser string `json:"browser"`
Editor string `json:"editor"`
Pager string `json:"pager"`
ImageViewer string `json:"imageViewer"`
VideoPlayer string `json:"videoPlayer"`
}
func NewConfigFromFile(configDir string) (*Config, error) {
path, err := configPath(configDir)
if err != nil {
return nil, fmt.Errorf("unable to calculate the path to your config file: %w", err)
}
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("unable to open %s: %w", path, err)
}
defer file.Close()
var config Config
if err := json.NewDecoder(file).Decode(&config); err != nil {
return nil, fmt.Errorf("unable to decode the JSON data: %w", err)
}
return &config, nil
}
func FileExists(configDir string) (bool, error) {
path, err := configPath(configDir)
if err != nil {
return false, fmt.Errorf("unable to calculate the path to your config file: %w", err)
}
return utilities.FileExists(path)
}
func SaveDefaultConfigToFile(configDir string) error {
path, err := configPath(configDir)
if err != nil {
return fmt.Errorf("unable to calculate the path to your config file: %w", err)
}
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("unable to create the file at %s: %w", path, err)
}
defer file.Close()
config := defaultConfig()
credentialsFilePath, err := defaultCredentialsConfigFile(configDir)
if err != nil {
return fmt.Errorf("unable to calculate the path to the credentials file: %w", err)
}
config.CredentialsFile = credentialsFilePath
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(config); err != nil {
return fmt.Errorf("unable to save the JSON data to the config file: %w", err)
}
return nil
}
func configPath(configDir string) (string, error) {
configDir, err := utilities.CalculateConfigDir(configDir)
if err != nil {
return "", fmt.Errorf("unable to get the config directory: %w", err)
}
return filepath.Join(configDir, configFileName), nil
}
func defaultConfig() Config {
return Config{
CredentialsFile: "",
CacheDirectory: "",
HTTP: HTTPConfig{
Timeout: defaultHTTPTimeout,
MediaTimeout: defaultHTTPMediaTimeout,
},
LineWrapMaxWidth: defaultLineWrapMaxWidth,
Integrations: Integrations{
Browser: "",
Editor: "",
Pager: "",
ImageViewer: "",
VideoPlayer: "",
},
}
}

View file

@ -1,83 +0,0 @@
package config_test
import (
"fmt"
"os"
"path/filepath"
"testing"
"codeflow.dananglin.me.uk/apollo/enbas/internal/config"
)
func TestConfigFile(t *testing.T) {
t.Log("Testing saving and loading the configuration")
projectDir, err := projectRoot()
if err != nil {
t.Fatalf("Unable to get the project root directory: %v", err)
}
configDir := filepath.Join(projectDir, "test", "config")
t.Run("Save the default configuration to file", testSaveDefaultConfigToFile(configDir))
t.Run("Load the configuration from file", testLoadConfigFromFile(configDir))
expectedConfigFile := filepath.Join(configDir, "config.json")
if err := os.Remove(expectedConfigFile); err != nil {
t.Fatalf(
"received an error after trying to clean up the test configuration at %q: %v",
expectedConfigFile,
err,
)
}
}
func testSaveDefaultConfigToFile(configDir string) func(t *testing.T) {
return func(t *testing.T) {
if err := config.SaveDefaultConfigToFile(configDir); err != nil {
t.Fatalf("Unable to save the default configuration within %q: %v", configDir, err)
}
fileExists, err := config.FileExists(configDir)
if err != nil {
t.Fatalf("Unable to determine if the configuration exists within %q: %v", configDir, err)
}
if !fileExists {
t.Fatalf("The configuration does not appear to exist within %q", configDir)
}
}
}
func testLoadConfigFromFile(configDir string) func(t *testing.T) {
return func(t *testing.T) {
config, err := config.NewConfigFromFile(configDir)
if err != nil {
t.Fatalf("Unable to load the configuration from file: %v", err)
}
expectedDefaultHTTPTimeout := 5
if config.HTTP.Timeout != expectedDefaultHTTPTimeout {
t.Errorf(
"Unexpected HTTP Timeout settings loaded from the configuration: want %d, got %d",
expectedDefaultHTTPTimeout,
config.HTTP.Timeout,
)
} else {
t.Logf(
"Expected HTTP Timeout settings loaded from the configuration: got %d",
config.HTTP.Timeout,
)
}
}
}
func projectRoot() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("unable to get the current working directory, %w", err)
}
return filepath.Join(cwd, "..", ".."), nil
}

View file

@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package config package config
import ( import (
@ -6,12 +10,11 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
) )
const ( const (
defaultCredentialsFileName = "credentials.json" credentialsFileName = "credentials.json"
) )
type CredentialsConfig struct { type CredentialsConfig struct {
@ -37,35 +40,35 @@ func (e CredentialsNotFoundError) Error() string {
// SaveCredentials saves the credentials into the credentials file within the specified configuration // SaveCredentials saves the credentials into the credentials file within the specified configuration
// directory. If the directory is not specified then the default directory is used. If the directory // directory. If the directory is not specified then the default directory is used. If the directory
// is not present, it will be created. // is not present, it will be created.
func SaveCredentials(filePath, username string, credentials Credentials) (string, error) { func SaveCredentials(configDir, username string, credentials Credentials) (string, error) {
part := filepath.Dir(filePath) if err := ensureConfigDir(calculateConfigDir(configDir)); err != nil {
// ensure that the directory exists.
credentialsDir, err := utilities.CalculateConfigDir(part)
if err != nil {
return "", fmt.Errorf("unable to calculate the directory to your credentials file: %w", err)
}
if err := utilities.EnsureDirectory(credentialsDir); err != nil {
return "", fmt.Errorf("unable to ensure the configuration directory: %w", err) return "", fmt.Errorf("unable to ensure the configuration directory: %w", err)
} }
var authConfig CredentialsConfig var authConfig CredentialsConfig
if _, err := os.Stat(filePath); err != nil { filepath := credentialsConfigFile(configDir)
if _, err := os.Stat(filepath); err != nil {
if !errors.Is(err, os.ErrNotExist) { if !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("unknown error received when running stat on %s: %w", filePath, err) return "", fmt.Errorf("unknown error received when running stat on %s: %w", filepath, err)
} }
authConfig.Credentials = make(map[string]Credentials) authConfig.Credentials = make(map[string]Credentials)
} else { } else {
authConfig, err = NewCredentialsConfigFromFile(filePath) authConfig, err = NewCredentialsConfigFromFile(configDir)
if err != nil { if err != nil {
return "", fmt.Errorf("unable to retrieve the existing authentication configuration: %w", err) return "", fmt.Errorf("unable to retrieve the existing authentication configuration: %w", err)
} }
} }
instance := utilities.GetFQDN(credentials.Instance) instance := ""
if strings.HasPrefix(credentials.Instance, "https://") {
instance = strings.TrimPrefix(credentials.Instance, "https://")
} else if strings.HasPrefix(credentials.Instance, "http://") {
instance = strings.TrimPrefix(credentials.Instance, "http://")
}
authenticationName := username + "@" + instance authenticationName := username + "@" + instance
@ -73,15 +76,15 @@ func SaveCredentials(filePath, username string, credentials Credentials) (string
authConfig.Credentials[authenticationName] = credentials authConfig.Credentials[authenticationName] = credentials
if err := saveCredentialsConfigFile(authConfig, filePath); err != nil { if err := saveCredentialsConfigFile(authConfig, configDir); err != nil {
return "", fmt.Errorf("unable to save the authentication configuration to file: %w", err) return "", fmt.Errorf("unable to save the authentication configuration to file: %w", err)
} }
return authenticationName, nil return authenticationName, nil
} }
func UpdateCurrentAccount(account string, filePath string) error { func UpdateCurrentAccount(account string, configDir string) error {
credentialsConfig, err := NewCredentialsConfigFromFile(filePath) credentialsConfig, err := NewCredentialsConfigFromFile(configDir)
if err != nil { if err != nil {
return fmt.Errorf("unable to retrieve the existing authentication configuration: %w", err) return fmt.Errorf("unable to retrieve the existing authentication configuration: %w", err)
} }
@ -92,19 +95,19 @@ func UpdateCurrentAccount(account string, filePath string) error {
credentialsConfig.CurrentAccount = account credentialsConfig.CurrentAccount = account
if err := saveCredentialsConfigFile(credentialsConfig, filePath); err != nil { if err := saveCredentialsConfigFile(credentialsConfig, configDir); err != nil {
return fmt.Errorf("unable to save the authentication configuration to file: %w", err) return fmt.Errorf("unable to save the authentication configuration to file: %w", err)
} }
return nil return nil
} }
// NewCredentialsConfigFromFile creates a new CredentialsConfig value from reading func NewCredentialsConfigFromFile(configDir string) (CredentialsConfig, error) {
// the credentials file. path := credentialsConfigFile(configDir)
func NewCredentialsConfigFromFile(filePath string) (CredentialsConfig, error) {
file, err := os.Open(filePath) file, err := os.Open(path)
if err != nil { if err != nil {
return CredentialsConfig{}, fmt.Errorf("unable to open %s: %w", filePath, err) return CredentialsConfig{}, fmt.Errorf("unable to open %s, %w", path, err)
} }
defer file.Close() defer file.Close()
@ -117,11 +120,14 @@ func NewCredentialsConfigFromFile(filePath string) (CredentialsConfig, error) {
return authConfig, nil return authConfig, nil
} }
func saveCredentialsConfigFile(authConfig CredentialsConfig, filePath string) error { func saveCredentialsConfigFile(authConfig CredentialsConfig, configDir string) error {
file, err := os.Create(filePath) path := credentialsConfigFile(configDir)
file, err := os.Create(path)
if err != nil { if err != nil {
return fmt.Errorf("unable to create the file at %s: %w", filePath, err) return fmt.Errorf("unable to open %s: %w", path, err)
} }
defer file.Close() defer file.Close()
encoder := json.NewEncoder(file) encoder := json.NewEncoder(file)
@ -134,16 +140,6 @@ func saveCredentialsConfigFile(authConfig CredentialsConfig, filePath string) er
return nil return nil
} }
func defaultCredentialsConfigFile(configDir string) (string, error) { func credentialsConfigFile(configDir string) string {
dir, err := utilities.CalculateConfigDir(configDir) return filepath.Join(calculateConfigDir(configDir), credentialsFileName)
if err != nil {
return "", fmt.Errorf("unable to calculate the config directory: %w", err)
}
path, err := utilities.AbsolutePath(filepath.Join(dir, defaultCredentialsFileName))
if err != nil {
return "", fmt.Errorf("unable to get the absolute path to the credentials config file: %w", err)
}
return path, nil
} }

View file

@ -1,107 +0,0 @@
package config_test
import (
"maps"
"os"
"path/filepath"
"testing"
"codeflow.dananglin.me.uk/apollo/enbas/internal/config"
)
func TestCredentialsFile(t *testing.T) {
t.Log("Testing saving and loading credentials from file")
projectDir, err := projectRoot()
if err != nil {
t.Fatalf("Unable to get the project root directory: %v", err)
}
credentialsFile := filepath.Join(projectDir, "test", "config", "credentials.json")
credentialsMap := map[string]config.Credentials{
"admin": {
Instance: "https://gts.red-crow.private",
ClientID: "01EOB91DVQGPA364QK32TM3LXR1998BMXSZE4",
ClientSecret: "ffd76025-4b23-4ce6-b8ea-077ce3cadf5a",
AccessToken: "C9VDXGGRPZ0448SH562N6N6893VNPGJMGJ336TXLMH8RXGWF4",
},
"bobby": {
Instance: "https://gts.red-crow.private",
ClientID: "01CUVHR6LIST7Q6R25Z9Y14WZK780V91S9VQB",
ClientSecret: "379fc272-c7cc-4ccb-8461-f3f71207f798",
AccessToken: "F0YWQG1R4DDAMXGBZ514BCW7ATWN6JRGLDRUZO4RFAMTT6J38",
},
"app": {
Instance: "https://gts.red-crow.private",
ClientID: "01HLZY7XCD60564OP3RG6FZTOAD3LGF0R8SEK",
ClientSecret: "dfd8e954-53b1-4f00-9c09-0b181f44bb79",
AccessToken: "JZ2PZ4YNE1BB38VMRIQ7DNWXKZE6B1EBV310RNC53KQCVHXGB",
},
}
t.Run("Saving credentials to file", testSaveCredentials(credentialsFile, credentialsMap))
expectedCurrentAccount := "bobby@gts.red-crow.private"
t.Run("Updating the current account in the credentials file", testUpdateCurrentAccount(expectedCurrentAccount, credentialsFile))
t.Run("Loading the credentials from file", testLoadCredentialsConfigFromFile(credentialsFile, expectedCurrentAccount))
if err := os.Remove(credentialsFile); err != nil {
t.Fatalf(
"received an error after trying to clean up the test configuration at %q: %v",
credentialsFile,
err,
)
}
}
func testSaveCredentials(credentialsFile string, credentialsMap map[string]config.Credentials) func(t *testing.T) {
return func(t *testing.T) {
for username, credentials := range maps.All(credentialsMap) {
if _, err := config.SaveCredentials(credentialsFile, username, credentials); err != nil {
t.Fatalf(
"Unable to save the credentials for %s to %q: %v",
username,
credentialsFile,
err,
)
}
}
t.Log("All credentials saved to file.")
}
}
func testUpdateCurrentAccount(account, credentialsFile string) func(t *testing.T) {
return func(t *testing.T) {
if err := config.UpdateCurrentAccount(account, credentialsFile); err != nil {
t.Fatalf("Unable to update the current account to %q: %v", account, err)
}
t.Logf("Successfully updated the current account.")
}
}
func testLoadCredentialsConfigFromFile(credentialsFile string, expectedCurrentAccount string) func(t *testing.T) {
return func(t *testing.T) {
credentials, err := config.NewCredentialsConfigFromFile(credentialsFile)
if err != nil {
t.Fatalf(
"Unable to load the credentials configuration from %q: %v",
credentialsFile,
err,
)
}
if credentials.CurrentAccount != expectedCurrentAccount {
t.Errorf(
"Unexpected current account found in the credentials configuration file: want %s, got %s",
expectedCurrentAccount,
credentials.CurrentAccount,
)
} else {
t.Logf("Expected current account found in the credentials configuration file: got %s", credentials.CurrentAccount)
}
}
}

View file

@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"codeflow.dananglin.me.uk/apollo/enbas/internal"
)
func calculateConfigDir(configDir string) string {
if configDir != "" {
return configDir
}
rootDir, err := os.UserConfigDir()
if err != nil {
rootDir = "."
}
return filepath.Join(rootDir, internal.ApplicationName)
}
func ensureConfigDir(configDir string) error {
if _, err := os.Stat(configDir); err != nil {
if errors.Is(err, os.ErrNotExist) {
if err := os.MkdirAll(configDir, 0o750); err != nil {
return fmt.Errorf("unable to create %s: %w", configDir, err)
}
} else {
return fmt.Errorf("unknown error received after getting the config directory information: %w", err)
}
}
return nil
}

View file

@ -1,40 +0,0 @@
package executor
import (
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client"
)
func (a *AcceptExecutor) Execute() error {
funcMap := map[string]func(*client.Client) error{
resourceFollowRequest: a.acceptFollowRequest,
}
doFunc, ok := funcMap[a.resourceType]
if !ok {
return UnsupportedTypeError{resourceType: a.resourceType}
}
gtsClient, err := client.NewClientFromFile(a.config.CredentialsFile)
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err)
}
return doFunc(gtsClient)
}
func (a *AcceptExecutor) acceptFollowRequest(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, false, a.accountName)
if err != nil {
return fmt.Errorf("received an error while getting the account ID: %w", err)
}
if err := gtsClient.AcceptFollowRequest(accountID); err != nil {
return fmt.Errorf("unable to accept the follow request: %w", err)
}
a.printer.PrintSuccess("Successfully accepted the follow request.")
return nil
}

View file

@ -1,56 +1,68 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor package executor
import ( import (
"fmt" "fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client" "codeflow.dananglin.me.uk/apollo/enbas/internal/client"
internalFlag "codeflow.dananglin.me.uk/apollo/enbas/internal/flag" "codeflow.dananglin.me.uk/apollo/enbas/internal/config"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model" "codeflow.dananglin.me.uk/apollo/enbas/internal/model"
) )
func getAccountID( func getAccountID(gtsClient *client.Client, myAccount bool, accountName, configDir string) (string, error) {
gtsClient *client.Client,
myAccount bool,
accountNames internalFlag.StringSliceValue,
) (string, error) {
account, err := getAccount(gtsClient, myAccount, accountNames)
if err != nil {
return "", fmt.Errorf("unable to get the account information: %w", err)
}
return account.ID, nil
}
func getAccount(
gtsClient *client.Client,
myAccount bool,
accountNames internalFlag.StringSliceValue,
) (model.Account, error) {
var ( var (
account model.Account accountID string
err error err error
) )
switch { switch {
case myAccount: case myAccount:
account, err = getMyAccount(gtsClient) accountID, err = getMyAccountID(gtsClient, configDir)
if err != nil { if err != nil {
return account, fmt.Errorf("unable to get your account ID: %w", err) return "", fmt.Errorf("unable to get your account ID: %w", err)
} }
case !accountNames.Empty(): case accountName != "":
account, err = getOtherAccount(gtsClient, accountNames) accountID, err = getTheirAccountID(gtsClient, accountName)
if err != nil { if err != nil {
return account, fmt.Errorf("unable to get the account ID: %w", err) return "", fmt.Errorf("unable to get their account ID: %w", err)
} }
default: default:
return account, NoAccountSpecifiedError{} return "", NoAccountSpecifiedError{}
} }
return account, nil return accountID, nil
} }
func getMyAccount(gtsClient *client.Client) (model.Account, error) { func getTheirAccountID(gtsClient *client.Client, accountURI string) (string, error) {
account, err := gtsClient.VerifyCredentials() account, err := getAccount(gtsClient, accountURI)
if err != nil {
return "", fmt.Errorf("unable to retrieve your account: %w", err)
}
return account.ID, nil
}
func getMyAccountID(gtsClient *client.Client, configDir string) (string, error) {
account, err := getMyAccount(gtsClient, configDir)
if err != nil {
return "", fmt.Errorf("received an error while getting your account details: %w", err)
}
return account.ID, nil
}
func getMyAccount(gtsClient *client.Client, configDir string) (model.Account, error) {
authConfig, err := config.NewCredentialsConfigFromFile(configDir)
if err != nil {
return model.Account{}, fmt.Errorf("unable to retrieve the authentication configuration: %w", err)
}
accountURI := authConfig.CurrentAccount
account, err := getAccount(gtsClient, accountURI)
if err != nil { if err != nil {
return model.Account{}, fmt.Errorf("unable to retrieve your account: %w", err) return model.Account{}, fmt.Errorf("unable to retrieve your account: %w", err)
} }
@ -58,35 +70,11 @@ func getMyAccount(gtsClient *client.Client) (model.Account, error) {
return account, nil return account, nil
} }
func getOtherAccount(gtsClient *client.Client, accountNames internalFlag.StringSliceValue) (model.Account, error) { func getAccount(gtsClient *client.Client, accountURI string) (model.Account, error) {
expectedNumAccountNames := 1 account, err := gtsClient.GetAccount(accountURI)
if !accountNames.ExpectedLength(expectedNumAccountNames) {
return model.Account{}, fmt.Errorf(
"received an unexpected number of account names: want %d",
expectedNumAccountNames,
)
}
account, err := gtsClient.GetAccount(accountNames[0])
if err != nil { if err != nil {
return model.Account{}, fmt.Errorf("unable to retrieve the account details: %w", err) return model.Account{}, fmt.Errorf("unable to retrieve the account details: %w", err)
} }
return account, nil return account, nil
} }
func getOtherAccounts(gtsClient *client.Client, accountNames internalFlag.StringSliceValue) ([]model.Account, error) {
numAccountNames := len(accountNames)
accounts := make([]model.Account, numAccountNames)
for ind := range numAccountNames {
var err error
accounts[ind], err = gtsClient.GetAccount(accountNames[ind])
if err != nil {
return nil, fmt.Errorf("unable to retrieve the account information for %s: %w", accountNames[ind], err)
}
}
return accounts, nil
}

View file

@ -1,11 +1,49 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor package executor
import ( import (
"flag"
"fmt" "fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client" "codeflow.dananglin.me.uk/apollo/enbas/internal/client"
) )
type AddExecutor struct {
*flag.FlagSet
topLevelFlags TopLevelFlags
resourceType string
toResourceType string
listID string
statusID string
accountNames AccountNames
content string
}
func NewAddExecutor(tlf TopLevelFlags, name, summary string) *AddExecutor {
emptyArr := make([]string, 0, 3)
addExe := AddExecutor{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
accountNames: AccountNames(emptyArr),
topLevelFlags: tlf,
}
addExe.StringVar(&addExe.resourceType, flagType, "", "specify the resource type to add (e.g. account, note)")
addExe.StringVar(&addExe.toResourceType, flagTo, "", "specify the target resource type to add to (e.g. list, account, etc)")
addExe.StringVar(&addExe.listID, flagListID, "", "the ID of the list to add to")
addExe.StringVar(&addExe.statusID, flagStatusID, "", "the ID of the status")
addExe.Var(&addExe.accountNames, flagAccountName, "the name of the account")
addExe.StringVar(&addExe.content, flagContent, "", "the content of the note")
addExe.Usage = commandUsageFunc(name, summary, addExe.FlagSet)
return &addExe
}
func (a *AddExecutor) Execute() error { func (a *AddExecutor) Execute() error {
if a.toResourceType == "" { if a.toResourceType == "" {
return FlagNotSetError{flagText: flagTo} return FlagNotSetError{flagText: flagTo}
@ -15,7 +53,6 @@ func (a *AddExecutor) Execute() error {
resourceList: a.addToList, resourceList: a.addToList,
resourceAccount: a.addToAccount, resourceAccount: a.addToAccount,
resourceBookmarks: a.addToBookmarks, resourceBookmarks: a.addToBookmarks,
resourceStatus: a.addToStatus,
} }
doFunc, ok := funcMap[a.toResourceType] doFunc, ok := funcMap[a.toResourceType]
@ -23,7 +60,7 @@ func (a *AddExecutor) Execute() error {
return UnsupportedTypeError{resourceType: a.toResourceType} return UnsupportedTypeError{resourceType: a.toResourceType}
} }
gtsClient, err := client.NewClientFromFile(a.config.CredentialsFile) gtsClient, err := client.NewClientFromConfig(a.topLevelFlags.ConfigDir)
if err != nil { if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err) return fmt.Errorf("unable to create the GoToSocial client: %w", err)
} }
@ -32,13 +69,6 @@ func (a *AddExecutor) Execute() error {
} }
func (a *AddExecutor) addToList(gtsClient *client.Client) error { func (a *AddExecutor) addToList(gtsClient *client.Client) error {
if a.listID == "" {
return MissingIDError{
resource: resourceList,
action: "add to",
}
}
funcMap := map[string]func(*client.Client) error{ funcMap := map[string]func(*client.Client) error{
resourceAccount: a.addAccountsToList, resourceAccount: a.addAccountsToList,
} }
@ -46,8 +76,8 @@ func (a *AddExecutor) addToList(gtsClient *client.Client) error {
doFunc, ok := funcMap[a.resourceType] doFunc, ok := funcMap[a.resourceType]
if !ok { if !ok {
return UnsupportedAddOperationError{ return UnsupportedAddOperationError{
resourceType: a.resourceType, ResourceType: a.resourceType,
addToResourceType: a.toResourceType, AddToResourceType: a.toResourceType,
} }
} }
@ -55,35 +85,30 @@ func (a *AddExecutor) addToList(gtsClient *client.Client) error {
} }
func (a *AddExecutor) addAccountsToList(gtsClient *client.Client) error { func (a *AddExecutor) addAccountsToList(gtsClient *client.Client) error {
if a.accountNames.Empty() { if a.listID == "" {
return FlagNotSetError{flagText: flagListID}
}
if len(a.accountNames) == 0 {
return NoAccountSpecifiedError{} return NoAccountSpecifiedError{}
} }
accounts, err := getOtherAccounts(gtsClient, a.accountNames) accountIDs := make([]string, len(a.accountNames))
for ind := range a.accountNames {
accountID, err := getTheirAccountID(gtsClient, a.accountNames[ind])
if err != nil { if err != nil {
return fmt.Errorf("unable to get the accounts: %w", err) return fmt.Errorf("unable to get the account ID for %s, %w", a.accountNames[ind], err)
} }
accountIDs := make([]string, len(accounts)) accountIDs[ind] = accountID
for ind := range accounts {
relationship, err := gtsClient.GetAccountRelationship(accounts[ind].ID)
if err != nil {
return fmt.Errorf("unable to get your relationship to %s: %w", accounts[ind].Acct, err)
}
if !relationship.Following {
return NotFollowingError{account: accounts[ind].Acct}
}
accountIDs[ind] = accounts[ind].ID
} }
if err := gtsClient.AddAccountsToList(a.listID, accountIDs); err != nil { if err := gtsClient.AddAccountsToList(a.listID, accountIDs); err != nil {
return fmt.Errorf("unable to add the accounts to the list: %w", err) return fmt.Errorf("unable to add the accounts to the list: %w", err)
} }
a.printer.PrintSuccess("Successfully added the account(s) to the list.") fmt.Println("Successfully added the account(s) to the list.")
return nil return nil
} }
@ -96,8 +121,8 @@ func (a *AddExecutor) addToAccount(gtsClient *client.Client) error {
doFunc, ok := funcMap[a.resourceType] doFunc, ok := funcMap[a.resourceType]
if !ok { if !ok {
return UnsupportedAddOperationError{ return UnsupportedAddOperationError{
resourceType: a.resourceType, ResourceType: a.resourceType,
addToResourceType: a.toResourceType, AddToResourceType: a.toResourceType,
} }
} }
@ -105,20 +130,27 @@ func (a *AddExecutor) addToAccount(gtsClient *client.Client) error {
} }
func (a *AddExecutor) addNoteToAccount(gtsClient *client.Client) error { func (a *AddExecutor) addNoteToAccount(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, false, a.accountNames) if len(a.accountNames) != 1 {
return fmt.Errorf("unexpected number of accounts specified: want 1, got %d", len(a.accountNames))
}
accountID, err := getAccountID(gtsClient, false, a.accountNames[0], a.topLevelFlags.ConfigDir)
if err != nil { if err != nil {
return fmt.Errorf("received an error while getting the account ID: %w", err) return fmt.Errorf("received an error while getting the account ID: %w", err)
} }
if a.content == "" { if a.content == "" {
return Error{"please add content to the note you want to add"} return EmptyContentError{
ResourceType: resourceNote,
Hint: "please use --" + flagContent,
}
} }
if err := gtsClient.SetPrivateNote(accountID, a.content); err != nil { if err := gtsClient.SetPrivateNote(accountID, a.content); err != nil {
return fmt.Errorf("unable to add the private note to the account: %w", err) return fmt.Errorf("unable to add the private note to the account: %w", err)
} }
a.printer.PrintSuccess("Successfully added the private note to the account.") fmt.Println("Successfully added the private note to the account.")
return nil return nil
} }
@ -131,8 +163,8 @@ func (a *AddExecutor) addToBookmarks(gtsClient *client.Client) error {
doFunc, ok := funcMap[a.resourceType] doFunc, ok := funcMap[a.resourceType]
if !ok { if !ok {
return UnsupportedAddOperationError{ return UnsupportedAddOperationError{
resourceType: a.resourceType, ResourceType: a.resourceType,
addToResourceType: a.toResourceType, AddToResourceType: a.toResourceType,
} }
} }
@ -141,105 +173,14 @@ func (a *AddExecutor) addToBookmarks(gtsClient *client.Client) error {
func (a *AddExecutor) addStatusToBookmarks(gtsClient *client.Client) error { func (a *AddExecutor) addStatusToBookmarks(gtsClient *client.Client) error {
if a.statusID == "" { if a.statusID == "" {
return MissingIDError{ return FlagNotSetError{flagText: flagStatusID}
resource: resourceStatus,
action: "add to your bookmarks",
}
} }
if err := gtsClient.AddStatusToBookmarks(a.statusID); err != nil { if err := gtsClient.AddStatusToBookmarks(a.statusID); err != nil {
return fmt.Errorf("unable to add the status to your bookmarks: %w", err) return fmt.Errorf("unable to add the status to your bookmarks: %w", err)
} }
a.printer.PrintSuccess("Successfully added the status to your bookmarks.") fmt.Println("Successfully added the status to your bookmarks.")
return nil
}
func (a *AddExecutor) addToStatus(gtsClient *client.Client) error {
if a.statusID == "" {
return MissingIDError{
resource: resourceStatus,
action: "add to",
}
}
funcMap := map[string]func(*client.Client) error{
resourceStar: a.addStarToStatus,
resourceLike: a.addStarToStatus,
resourceBoost: a.addBoostToStatus,
resourceVote: a.addVoteToStatus,
}
doFunc, ok := funcMap[a.resourceType]
if !ok {
return UnsupportedAddOperationError{
resourceType: a.resourceType,
addToResourceType: a.toResourceType,
}
}
return doFunc(gtsClient)
}
func (a *AddExecutor) addStarToStatus(gtsClient *client.Client) error {
if err := gtsClient.LikeStatus(a.statusID); err != nil {
return fmt.Errorf("unable to add the %s to the status: %w", a.resourceType, err)
}
a.printer.PrintSuccess("Successfully added a " + a.resourceType + " to the status.")
return nil
}
func (a *AddExecutor) addBoostToStatus(gtsClient *client.Client) error {
if err := gtsClient.ReblogStatus(a.statusID); err != nil {
return fmt.Errorf("unable to add the boost to the status: %w", err)
}
a.printer.PrintSuccess("Successfully added the boost to the status.")
return nil
}
func (a *AddExecutor) addVoteToStatus(gtsClient *client.Client) error {
if a.votes.Empty() {
return Error{"please add your vote(s) to the poll"}
}
status, err := gtsClient.GetStatus(a.statusID)
if err != nil {
return fmt.Errorf("unable to get the status: %w", err)
}
if status.Poll == nil {
return Error{"this status does not have a poll"}
}
if status.Poll.Expired {
return Error{"this poll is closed"}
}
if !status.Poll.Multiple && !a.votes.ExpectedLength(1) {
return Error{"this poll does not allow multiple choices"}
}
myAccountID, err := getAccountID(gtsClient, true, nil)
if err != nil {
return fmt.Errorf("unable to get your account ID: %w", err)
}
if status.Account.ID == myAccountID {
return Error{"you cannot vote in your own poll"}
}
pollID := status.Poll.ID
if err := gtsClient.VoteInPoll(pollID, []int(a.votes)); err != nil {
return fmt.Errorf("unable to add your vote(s) to the poll: %w", err)
}
a.printer.PrintSuccess("Successfully added your vote(s) to the poll.")
return nil return nil
} }

View file

@ -1,11 +1,41 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor package executor
import ( import (
"flag"
"fmt" "fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client" "codeflow.dananglin.me.uk/apollo/enbas/internal/client"
) )
type BlockExecutor struct {
*flag.FlagSet
topLevelFlags TopLevelFlags
resourceType string
accountName string
unblock bool
}
func NewBlockExecutor(tlf TopLevelFlags, name, summary string, unblock bool) *BlockExecutor {
blockExe := BlockExecutor{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
topLevelFlags: tlf,
unblock: unblock,
}
blockExe.StringVar(&blockExe.resourceType, flagType, "", "specify the type of resource to block or unblock")
blockExe.StringVar(&blockExe.accountName, flagAccountName, "", "specify the account name in full (username@domain)")
blockExe.Usage = commandUsageFunc(name, summary, blockExe.FlagSet)
return &blockExe
}
func (b *BlockExecutor) Execute() error { func (b *BlockExecutor) Execute() error {
funcMap := map[string]func(*client.Client) error{ funcMap := map[string]func(*client.Client) error{
resourceAccount: b.blockAccount, resourceAccount: b.blockAccount,
@ -16,7 +46,7 @@ func (b *BlockExecutor) Execute() error {
return UnsupportedTypeError{resourceType: b.resourceType} return UnsupportedTypeError{resourceType: b.resourceType}
} }
gtsClient, err := client.NewClientFromFile(b.config.CredentialsFile) gtsClient, err := client.NewClientFromConfig(b.topLevelFlags.ConfigDir)
if err != nil { if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err) return fmt.Errorf("unable to create the GoToSocial client: %w", err)
} }
@ -25,16 +55,30 @@ func (b *BlockExecutor) Execute() error {
} }
func (b *BlockExecutor) blockAccount(gtsClient *client.Client) error { func (b *BlockExecutor) blockAccount(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, false, b.accountName) accountID, err := getAccountID(gtsClient, false, b.accountName, b.topLevelFlags.ConfigDir)
if err != nil { if err != nil {
return fmt.Errorf("received an error while getting the account ID: %w", err) return fmt.Errorf("received an error while getting the account ID: %w", err)
} }
if b.unblock {
return b.unblockAccount(gtsClient, accountID)
}
if err := gtsClient.BlockAccount(accountID); err != nil { if err := gtsClient.BlockAccount(accountID); err != nil {
return fmt.Errorf("unable to block the account: %w", err) return fmt.Errorf("unable to block the account: %w", err)
} }
b.printer.PrintSuccess("Successfully blocked the account.") fmt.Println("Successfully blocked the account.")
return nil
}
func (b *BlockExecutor) unblockAccount(gtsClient *client.Client, accountID string) error {
if err := gtsClient.UnblockAccount(accountID); err != nil {
return fmt.Errorf("unable to unblock the account: %w", err)
}
fmt.Println("Successfully unblocked the account.")
return nil return nil
} }

View file

@ -1,3 +0,0 @@
package executor
//go:generate go run ../../cmd/enbas-codegen --package executor --path-to-enbas-cli-schema ../../schema/enbas_cli_schema.json

View file

@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor
const (
flagAccountName = "account-name"
flagBrowser = "browser"
flagContentType = "content-type"
flagContent = "content"
flagEnableFederation = "enable-federation"
flagEnableLikes = "enable-likes"
flagEnableReplies = "enable-replies"
flagEnableReposts = "enable-reposts"
flagFrom = "from"
flagFromFile = "from-file"
flagInstance = "instance"
flagLanguage = "language"
flagLimit = "limit"
flagListID = "list-id"
flagListTitle = "list-title"
flagListRepliesPolicy = "list-replies-policy"
flagMyAccount = "my-account"
flagNotify = "notify"
flagSensitive = "sensitive"
flagSkipRelationship = "skip-relationship"
flagShowPreferences = "show-preferences"
flagShowReposts = "show-reposts"
flagSpoilerText = "spoiler-text"
flagStatusID = "status-id"
flagTag = "tag"
flagTimelineCategory = "timeline-category"
flagTo = "to"
flagType = "type"
flagVisibility = "visibility"
resourceAccount = "account"
resourceBlocked = "blocked"
resourceFollowers = "followers"
resourceFollowing = "following"
resourceInstance = "instance"
resourceList = "list"
resourceNote = "note"
resourceStatus = "status"
resourceTimeline = "timeline"
resourceBookmarks = "bookmarks"
resourceNotification = "notification"
)

View file

@ -1,29 +0,0 @@
package executor
const (
flagFrom string = "from"
flagTo string = "to"
flagType string = "type"
resourceAccount string = "account"
resourceBlocked string = "blocked"
resourceBookmarks string = "bookmarks"
resourceBoost string = "boost"
resourceFollowers string = "followers"
resourceFollowing string = "following"
resourceFollowRequest string = "follow-request"
resourceInstance string = "instance"
resourceLike string = "like"
resourceLiked string = "liked"
resourceList string = "list"
resourceMedia string = "media"
resourceMediaAttachment string = "media-attachment"
resourceMutedAccounts string = "muted-accounts"
resourceNote string = "note"
resourcePoll string = "poll"
resourceStatus string = "status"
resourceStar string = "star"
resourceStarred string = "starred"
resourceTimeline string = "timeline"
resourceVote string = "vote"
)

View file

@ -1,19 +1,83 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor package executor
import ( import (
"flag"
"fmt" "fmt"
"strconv"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client" "codeflow.dananglin.me.uk/apollo/enbas/internal/client"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model" "codeflow.dananglin.me.uk/apollo/enbas/internal/model"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities" "codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
) )
type CreateExecutor struct {
*flag.FlagSet
topLevelFlags TopLevelFlags
boostable bool
federated bool
likeable bool
replyable bool
sensitive *bool
content string
contentType string
fromFile string
language string
spoilerText string
resourceType string
listTitle string
listRepliesPolicy string
visibility string
}
func NewCreateExecutor(tlf TopLevelFlags, name, summary string) *CreateExecutor {
createExe := CreateExecutor{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
topLevelFlags: tlf,
}
createExe.BoolVar(&createExe.boostable, flagEnableReposts, true, "specify if the status can be reposted/boosted by others")
createExe.BoolVar(&createExe.federated, flagEnableFederation, true, "specify if the status can be federated beyond the local timelines")
createExe.BoolVar(&createExe.likeable, flagEnableLikes, true, "specify if the status can be liked/favourited")
createExe.BoolVar(&createExe.replyable, flagEnableReplies, true, "specify if the status can be replied to")
createExe.StringVar(&createExe.content, flagContent, "", "the content of the status to create")
createExe.StringVar(&createExe.contentType, flagContentType, "plain", "the type that the contents should be parsed from (valid values are plain and markdown)")
createExe.StringVar(&createExe.fromFile, flagFromFile, "", "the file path where to read the contents from")
createExe.StringVar(&createExe.language, flagLanguage, "", "the ISO 639 language code for this status")
createExe.StringVar(&createExe.spoilerText, flagSpoilerText, "", "the text to display as the status' warning or subject")
createExe.StringVar(&createExe.visibility, flagVisibility, "", "the visibility of the posted status")
createExe.StringVar(&createExe.resourceType, flagType, "", "specify the type of resource to create")
createExe.StringVar(&createExe.listTitle, flagListTitle, "", "specify the title of the list")
createExe.StringVar(&createExe.listRepliesPolicy, flagListRepliesPolicy, "list", "specify the policy of the replies for this list (valid values are followed, list and none)")
createExe.BoolFunc(flagSensitive, "specify if the status should be marked as sensitive", func(value string) error {
boolVal, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("unable to parse %q as a boolean value: %w", value, err)
}
createExe.sensitive = new(bool)
*createExe.sensitive = boolVal
return nil
})
createExe.Usage = commandUsageFunc(name, summary, createExe.FlagSet)
return &createExe
}
func (c *CreateExecutor) Execute() error { func (c *CreateExecutor) Execute() error {
if c.resourceType == "" { if c.resourceType == "" {
return FlagNotSetError{flagText: flagType} return FlagNotSetError{flagText: flagType}
} }
gtsClient, err := client.NewClientFromFile(c.config.CredentialsFile) gtsClient, err := client.NewClientFromConfig(c.topLevelFlags.ConfigDir)
if err != nil { if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err) return fmt.Errorf("unable to create the GoToSocial client: %w", err)
} }
@ -21,7 +85,6 @@ func (c *CreateExecutor) Execute() error {
funcMap := map[string]func(*client.Client) error{ funcMap := map[string]func(*client.Client) error{
resourceList: c.createList, resourceList: c.createList,
resourceStatus: c.createStatus, resourceStatus: c.createStatus,
resourceMediaAttachment: c.createMediaAttachment,
} }
doFunc, ok := funcMap[c.resourceType] doFunc, ok := funcMap[c.resourceType]
@ -34,12 +97,12 @@ func (c *CreateExecutor) Execute() error {
func (c *CreateExecutor) createList(gtsClient *client.Client) error { func (c *CreateExecutor) createList(gtsClient *client.Client) error {
if c.listTitle == "" { if c.listTitle == "" {
return Error{"please provide the title of the list that you want to create"} return FlagNotSetError{flagText: flagListTitle}
} }
parsedListRepliesPolicy, err := model.ParseListRepliesPolicy(c.listRepliesPolicy) parsedListRepliesPolicy, err := model.ParseListRepliesPolicy(c.listRepliesPolicy)
if err != nil { if err != nil {
return err //nolint:wrapcheck return err
} }
form := client.CreateListForm{ form := client.CreateListForm{
@ -52,8 +115,8 @@ func (c *CreateExecutor) createList(gtsClient *client.Client) error {
return fmt.Errorf("unable to create the list: %w", err) return fmt.Errorf("unable to create the list: %w", err)
} }
c.printer.PrintSuccess("Successfully created the following list:") fmt.Println("Successfully created the following list:")
c.printer.PrintList(list) utilities.Display(list, *c.topLevelFlags.NoColor)
return nil return nil
} }
@ -61,102 +124,30 @@ func (c *CreateExecutor) createList(gtsClient *client.Client) error {
func (c *CreateExecutor) createStatus(gtsClient *client.Client) error { func (c *CreateExecutor) createStatus(gtsClient *client.Client) error {
var ( var (
err error err error
content string
language string language string
visibility string visibility string
sensitive bool sensitive bool
) )
attachmentIDs := []string(c.attachmentIDs) switch {
case c.content != "":
if !c.mediaFiles.Empty() { content = c.content
descriptionsExists := false case c.fromFile != "":
focusValuesExists := false content, err = utilities.ReadFile(c.fromFile)
numMediaFiles := len(c.mediaFiles)
mediaDescriptions := make([]string, numMediaFiles)
if !c.mediaDescriptions.Empty() {
descriptionsExists = true
if !c.mediaDescriptions.ExpectedLength(numMediaFiles) {
return MismatchedNumMediaValuesError{
valueType: "media descriptions",
numValues: len(c.mediaDescriptions),
numMediaFiles: numMediaFiles,
}
}
}
if !c.mediaFocusValues.Empty() {
focusValuesExists = true
if !c.mediaFocusValues.ExpectedLength(numMediaFiles) {
return MismatchedNumMediaValuesError{
valueType: "media focus values",
numValues: len(c.mediaFocusValues),
numMediaFiles: numMediaFiles,
}
}
}
if descriptionsExists {
for ind := range numMediaFiles {
mediaDesc, err := utilities.ReadContents(c.mediaDescriptions[ind])
if err != nil { if err != nil {
return fmt.Errorf("unable to read the contents from %s: %w", c.mediaDescriptions[ind], err) return fmt.Errorf("unable to get the status contents from %q: %w", c.fromFile, err)
} }
default:
mediaDescriptions[ind] = mediaDesc return EmptyContentError{
ResourceType: resourceStatus,
Hint: "please use --" + flagContent + " or --" + flagFromFile,
} }
} }
for ind := range numMediaFiles {
var (
mediaFile string
description string
focus string
)
mediaFile = c.mediaFiles[ind]
if descriptionsExists {
description = mediaDescriptions[ind]
}
if focusValuesExists {
focus = c.mediaFocusValues[ind]
}
attachment, err := gtsClient.CreateMediaAttachment(
mediaFile,
description,
focus,
)
if err != nil {
return fmt.Errorf("unable to create the media attachment for %s: %w", mediaFile, err)
}
attachmentIDs = append(attachmentIDs, attachment.ID)
}
}
if c.content == "" && len(attachmentIDs) == 0 {
return Error{"please add content to the status that you want to create"}
}
content, err := utilities.ReadContents(c.content)
if err != nil {
return fmt.Errorf("unable to read the contents from %s: %w", c.content, err)
}
numAttachmentIDs := len(attachmentIDs)
if c.addPoll && numAttachmentIDs > 0 {
return Error{"attaching media to a poll is not allowed"}
}
preferences, err := gtsClient.GetUserPreferences() preferences, err := gtsClient.GetUserPreferences()
if err != nil { if err != nil {
c.printer.PrintInfo("WARNING: Unable to get your posting preferences: " + err.Error() + ".\n") fmt.Println("WARNING: Unable to get your posting preferences: %w", err)
} }
if c.language != "" { if c.language != "" {
@ -171,55 +162,33 @@ func (c *CreateExecutor) createStatus(gtsClient *client.Client) error {
visibility = preferences.PostingDefaultVisibility visibility = preferences.PostingDefaultVisibility
} }
if c.sensitive.Value != nil { if c.sensitive != nil {
sensitive = *c.sensitive.Value sensitive = *c.sensitive
} else { } else {
sensitive = preferences.PostingDefaultSensitive sensitive = preferences.PostingDefaultSensitive
} }
parsedVisibility, err := model.ParseStatusVisibility(visibility) parsedVisibility, err := model.ParseStatusVisibility(visibility)
if err != nil { if err != nil {
return err //nolint:wrapcheck return err
} }
parsedContentType, err := model.ParseStatusContentType(c.contentType) parsedContentType, err := model.ParseStatusContentType(c.contentType)
if err != nil { if err != nil {
return err //nolint:wrapcheck return err
} }
form := client.CreateStatusForm{ form := client.CreateStatusForm{
Content: content, Content: content,
ContentType: parsedContentType, ContentType: parsedContentType,
Language: language, Language: language,
SpoilerText: c.summary, SpoilerText: c.spoilerText,
Boostable: c.boostable, Boostable: c.boostable,
Federated: c.federated, Federated: c.federated,
InReplyTo: c.inReplyTo,
Likeable: c.likeable, Likeable: c.likeable,
Replyable: c.replyable, Replyable: c.replyable,
Sensitive: sensitive, Sensitive: sensitive,
Visibility: parsedVisibility, Visibility: parsedVisibility,
Poll: nil,
AttachmentIDs: nil,
}
if numAttachmentIDs > 0 {
form.AttachmentIDs = attachmentIDs
}
if c.addPoll {
if len(c.pollOptions) == 0 {
return Error{"no options were provided for this poll"}
}
poll := client.CreateStatusPollForm{
Options: c.pollOptions,
Multiple: c.pollAllowsMultipleChoices,
HideTotals: c.pollHidesVoteCounts,
ExpiresIn: int(c.pollExpiresIn.Duration.Seconds()),
}
form.Poll = &poll
} }
status, err := gtsClient.CreateStatus(form) status, err := gtsClient.CreateStatus(form)
@ -227,67 +196,8 @@ func (c *CreateExecutor) createStatus(gtsClient *client.Client) error {
return fmt.Errorf("unable to create the status: %w", err) return fmt.Errorf("unable to create the status: %w", err)
} }
c.printer.PrintSuccess("Successfully created the status with ID: " + status.ID) fmt.Println("Successfully created the following status:")
utilities.Display(status, *c.topLevelFlags.NoColor)
return nil
}
func (c *CreateExecutor) createMediaAttachment(gtsClient *client.Client) error {
expectedNumValues := 1
if !c.mediaFiles.ExpectedLength(expectedNumValues) {
return UnexpectedNumValuesError{
name: "media files",
expected: expectedNumValues,
actual: len(c.mediaFiles),
}
}
description := ""
if !c.mediaDescriptions.Empty() {
if !c.mediaDescriptions.ExpectedLength(expectedNumValues) {
return UnexpectedNumValuesError{
name: "media descriptions",
expected: expectedNumValues,
actual: len(c.mediaDescriptions),
}
}
var err error
description, err = utilities.ReadContents(c.mediaDescriptions[0])
if err != nil {
return fmt.Errorf(
"unable to read the contents from %s: %w",
c.mediaDescriptions[0],
err,
)
}
}
focus := ""
if !c.mediaFocusValues.Empty() {
if !c.mediaFocusValues.ExpectedLength(expectedNumValues) {
return UnexpectedNumValuesError{
name: "media focus values",
expected: expectedNumValues,
actual: len(c.mediaFocusValues),
}
}
focus = c.mediaFocusValues[0]
}
attachment, err := gtsClient.CreateMediaAttachment(
c.mediaFiles[0],
description,
focus,
)
if err != nil {
return fmt.Errorf("unable to create the media attachment: %w", err)
}
c.printer.PrintSuccess("Successfully created the media attachment with ID: " + attachment.ID)
return nil return nil
} }

View file

@ -1,13 +1,38 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor package executor
import ( import (
"flag"
"fmt" "fmt"
"path/filepath"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client" "codeflow.dananglin.me.uk/apollo/enbas/internal/client"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
) )
type DeleteExecutor struct {
*flag.FlagSet
topLevelFlags TopLevelFlags
resourceType string
listID string
}
func NewDeleteExecutor(tlf TopLevelFlags, name, summary string) *DeleteExecutor {
deleteExe := DeleteExecutor{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
topLevelFlags: tlf,
}
deleteExe.StringVar(&deleteExe.resourceType, flagType, "", "specify the type of resource to delete")
deleteExe.StringVar(&deleteExe.listID, flagListID, "", "specify the ID of the list to delete")
deleteExe.Usage = commandUsageFunc(name, summary, deleteExe.FlagSet)
return &deleteExe
}
func (d *DeleteExecutor) Execute() error { func (d *DeleteExecutor) Execute() error {
if d.resourceType == "" { if d.resourceType == "" {
return FlagNotSetError{flagText: flagType} return FlagNotSetError{flagText: flagType}
@ -15,7 +40,6 @@ func (d *DeleteExecutor) Execute() error {
funcMap := map[string]func(*client.Client) error{ funcMap := map[string]func(*client.Client) error{
resourceList: d.deleteList, resourceList: d.deleteList,
resourceStatus: d.deleteStatus,
} }
doFunc, ok := funcMap[d.resourceType] doFunc, ok := funcMap[d.resourceType]
@ -23,7 +47,7 @@ func (d *DeleteExecutor) Execute() error {
return UnsupportedTypeError{resourceType: d.resourceType} return UnsupportedTypeError{resourceType: d.resourceType}
} }
gtsClient, err := client.NewClientFromFile(d.config.CredentialsFile) gtsClient, err := client.NewClientFromConfig(d.topLevelFlags.ConfigDir)
if err != nil { if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err) return fmt.Errorf("unable to create the GoToSocial client: %w", err)
} }
@ -33,68 +57,14 @@ func (d *DeleteExecutor) Execute() error {
func (d *DeleteExecutor) deleteList(gtsClient *client.Client) error { func (d *DeleteExecutor) deleteList(gtsClient *client.Client) error {
if d.listID == "" { if d.listID == "" {
return MissingIDError{ return FlagNotSetError{flagText: flagListID}
resource: resourceList,
action: "delete",
}
} }
if err := gtsClient.DeleteList(d.listID); err != nil { if err := gtsClient.DeleteList(d.listID); err != nil {
return fmt.Errorf("unable to delete the list: %w", err) return fmt.Errorf("unable to delete the list: %w", err)
} }
d.printer.PrintSuccess("The list was successfully deleted.") fmt.Println("The list was successfully deleted.")
return nil
}
func (d *DeleteExecutor) deleteStatus(gtsClient *client.Client) error {
if d.statusID == "" {
return MissingIDError{
resource: resourceStatus,
action: "delete",
}
}
status, err := gtsClient.GetStatus(d.statusID)
if err != nil {
return fmt.Errorf("unable to get the status: %w", err)
}
myAccountID, err := getAccountID(gtsClient, true, nil)
if err != nil {
return fmt.Errorf("unable to get your account ID: %w", err)
}
if status.Account.ID != myAccountID {
return Error{"unable to delete the status because the status does not belong to you"}
}
text, err := gtsClient.DeleteStatus(d.statusID)
if err != nil {
return fmt.Errorf("unable to delete the status: %w", err)
}
d.printer.PrintSuccess("The status was successfully deleted.")
if d.saveText {
cacheDir, err := utilities.CalculateStatusesCacheDir(d.config.CacheDirectory, gtsClient.Authentication.Instance)
if err != nil {
return fmt.Errorf("unable to get the cache directory for the status: %w", err)
}
if err := utilities.EnsureDirectory(cacheDir); err != nil {
return fmt.Errorf("unable to ensure the existence of the directory %q: %w", cacheDir, err)
}
path := filepath.Join(cacheDir, fmt.Sprintf("deleted-status-%s.txt", d.statusID))
if err := utilities.SaveTextToFile(path, text); err != nil {
return fmt.Errorf("unable to save the text to %q: %w", path, err)
}
d.printer.PrintSuccess("The text was successfully saved to '" + path + "'.")
}
return nil return nil
} }

View file

@ -1,6 +1,11 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor package executor
import ( import (
"flag"
"fmt" "fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client" "codeflow.dananglin.me.uk/apollo/enbas/internal/client"
@ -8,6 +13,32 @@ import (
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities" "codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
) )
type EditExecutor struct {
*flag.FlagSet
topLevelFlags TopLevelFlags
resourceType string
listID string
listTitle string
listRepliesPolicy string
}
func NewEditExecutor(tlf TopLevelFlags, name, summary string) *EditExecutor {
editExe := EditExecutor{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
topLevelFlags: tlf,
}
editExe.StringVar(&editExe.resourceType, flagType, "", "specify the type of resource to update")
editExe.StringVar(&editExe.listID, flagListID, "", "specify the ID of the list to update")
editExe.StringVar(&editExe.listTitle, flagListTitle, "", "specify the title of the list")
editExe.StringVar(&editExe.listRepliesPolicy, flagListRepliesPolicy, "", "specify the policy of the replies for this list (valid values are followed, list and none)")
editExe.Usage = commandUsageFunc(name, summary, editExe.FlagSet)
return &editExe
}
func (e *EditExecutor) Execute() error { func (e *EditExecutor) Execute() error {
if e.resourceType == "" { if e.resourceType == "" {
return FlagNotSetError{flagText: flagType} return FlagNotSetError{flagText: flagType}
@ -15,7 +46,6 @@ func (e *EditExecutor) Execute() error {
funcMap := map[string]func(*client.Client) error{ funcMap := map[string]func(*client.Client) error{
resourceList: e.editList, resourceList: e.editList,
resourceMediaAttachment: e.editMediaAttachment,
} }
doFunc, ok := funcMap[e.resourceType] doFunc, ok := funcMap[e.resourceType]
@ -23,7 +53,7 @@ func (e *EditExecutor) Execute() error {
return UnsupportedTypeError{resourceType: e.resourceType} return UnsupportedTypeError{resourceType: e.resourceType}
} }
gtsClient, err := client.NewClientFromFile(e.config.CredentialsFile) gtsClient, err := client.NewClientFromConfig(e.topLevelFlags.ConfigDir)
if err != nil { if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err) return fmt.Errorf("unable to create the GoToSocial client: %w", err)
} }
@ -33,10 +63,7 @@ func (e *EditExecutor) Execute() error {
func (e *EditExecutor) editList(gtsClient *client.Client) error { func (e *EditExecutor) editList(gtsClient *client.Client) error {
if e.listID == "" { if e.listID == "" {
return MissingIDError{ return FlagNotSetError{flagText: flagListID}
resource: resourceList,
action: "edit",
}
} }
list, err := gtsClient.GetList(e.listID) list, err := gtsClient.GetList(e.listID)
@ -62,68 +89,8 @@ func (e *EditExecutor) editList(gtsClient *client.Client) error {
return fmt.Errorf("unable to update the list: %w", err) return fmt.Errorf("unable to update the list: %w", err)
} }
e.printer.PrintSuccess("Successfully edited the list.") fmt.Println("Successfully updated the list.")
e.printer.PrintList(updatedList) utilities.Display(updatedList, *e.topLevelFlags.NoColor)
return nil
}
func (e *EditExecutor) editMediaAttachment(gtsClient *client.Client) error {
expectedNumValues := 1
if !e.attachmentIDs.ExpectedLength(expectedNumValues) {
return UnexpectedNumValuesError{
name: "media attachment IDs",
expected: expectedNumValues,
actual: len(e.attachmentIDs),
}
}
attachment, err := gtsClient.GetMediaAttachment(e.attachmentIDs[0])
if err != nil {
return fmt.Errorf("unable to get the media attachment: %w", err)
}
description := attachment.Description
if !e.mediaDescriptions.Empty() {
if !e.mediaDescriptions.ExpectedLength(expectedNumValues) {
return UnexpectedNumValuesError{
name: "media description",
expected: expectedNumValues,
actual: len(e.mediaDescriptions),
}
}
var err error
description, err = utilities.ReadContents(e.mediaDescriptions[0])
if err != nil {
return fmt.Errorf(
"unable to read the contents from %s: %w",
e.mediaDescriptions[0],
err,
)
}
}
focus := fmt.Sprintf("%f,%f", attachment.Meta.Focus.X, attachment.Meta.Focus.Y)
if !e.mediaFocusValues.Empty() {
if !e.mediaFocusValues.ExpectedLength(expectedNumValues) {
return UnexpectedNumValuesError{
name: "media focus values",
expected: expectedNumValues,
actual: len(e.mediaFocusValues),
}
}
focus = e.mediaFocusValues[0]
}
if _, err = gtsClient.UpdateMediaAttachment(attachment.ID, description, focus); err != nil {
return fmt.Errorf("unable to update the media attachment: %w", err)
}
e.printer.PrintSuccess("Successfully edited the media attachment.")
return nil return nil
} }

View file

@ -1,15 +1,9 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor package executor
import "fmt"
type Error struct {
message string
}
func (e Error) Error() string {
return e.message
}
type FlagNotSetError struct { type FlagNotSetError struct {
flagText string flagText string
} }
@ -33,96 +27,34 @@ func (e NoAccountSpecifiedError) Error() string {
} }
type UnsupportedAddOperationError struct { type UnsupportedAddOperationError struct {
resourceType string ResourceType string
addToResourceType string AddToResourceType string
} }
func (e UnsupportedAddOperationError) Error() string { func (e UnsupportedAddOperationError) Error() string {
return "adding '" + return "adding '" + e.ResourceType + "' to '" + e.AddToResourceType + "' is not supported"
e.resourceType +
"' to '" +
e.addToResourceType +
"' is not supported"
} }
type UnsupportedRemoveOperationError struct { type UnsupportedRemoveOperationError struct {
resourceType string ResourceType string
removeFromResourceType string RemoveFromResourceType string
} }
func (e UnsupportedRemoveOperationError) Error() string { func (e UnsupportedRemoveOperationError) Error() string {
return "removing '" + return "removing '" + e.ResourceType + "' from '" + e.RemoveFromResourceType + "' is not supported"
e.resourceType +
"' from '" +
e.removeFromResourceType +
"' is not supported"
} }
type UnsupportedShowOperationError struct { type EmptyContentError struct {
resourceType string ResourceType string
showFromResourceType string Hint string
} }
func (e UnsupportedShowOperationError) Error() string { func (e EmptyContentError) Error() string {
return "showing '" + message := "the content of this " + e.ResourceType + " should not be empty"
e.resourceType +
"' from '" +
e.showFromResourceType +
"' is not supported"
}
type UnknownCommandError struct { if e.Hint != "" {
command string message += ", " + e.Hint
} }
func (e UnknownCommandError) Error() string { return message
return "unknown command '" + e.command + "'"
}
type NotFollowingError struct {
account string
}
func (e NotFollowingError) Error() string {
return "you are not following " + e.account
}
type MismatchedNumMediaValuesError struct {
valueType string
numValues int
numMediaFiles int
}
func (e MismatchedNumMediaValuesError) Error() string {
return fmt.Sprintf(
"unexpected number of %s: received %d media files but got %d %s",
e.valueType,
e.numMediaFiles,
e.numValues,
e.valueType,
)
}
type UnexpectedNumValuesError struct {
name string
actual int
expected int
}
func (e UnexpectedNumValuesError) Error() string {
return fmt.Sprintf(
"received an unexpected number of %s: received %d, expected %d",
e.name,
e.actual,
e.expected,
)
}
type MissingIDError struct {
resource string
action string
}
func (e MissingIDError) Error() string {
return "please provide the ID of the " + e.resource + " you want to " + e.action
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor
import "strings"
type AccountNames []string
func (a *AccountNames) String() string {
return strings.Join(*a, ", ")
}
func (a *AccountNames) Set(value string) error {
if len(value) > 0 {
*a = append(*a, value)
}
return nil
}
type TopLevelFlags struct {
ConfigDir string
NoColor *bool
}

View file

@ -1,22 +1,55 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor package executor
import ( import (
"flag"
"fmt" "fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client" "codeflow.dananglin.me.uk/apollo/enbas/internal/client"
) )
func (f *FollowExecutor) Execute() error { type FollowExecutor struct {
*flag.FlagSet
topLevelFlags TopLevelFlags
resourceType string
accountName string
showReposts bool
notify bool
unfollow bool
}
func NewFollowExecutor(tlf TopLevelFlags, name, summary string, unfollow bool) *FollowExecutor {
command := FollowExecutor{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
unfollow: unfollow,
topLevelFlags: tlf,
}
command.StringVar(&command.resourceType, flagType, "", "specify the type of resource to follow")
command.StringVar(&command.accountName, flagAccountName, "", "specify the account name in full (username@domain)")
command.BoolVar(&command.showReposts, flagShowReposts, true, "show reposts from the account you want to follow")
command.BoolVar(&command.notify, flagNotify, false, "get notifications when the account you want to follow posts a status")
command.Usage = commandUsageFunc(name, summary, command.FlagSet)
return &command
}
func (c *FollowExecutor) Execute() error {
funcMap := map[string]func(*client.Client) error{ funcMap := map[string]func(*client.Client) error{
resourceAccount: f.followAccount, resourceAccount: c.followAccount,
} }
doFunc, ok := funcMap[f.resourceType] doFunc, ok := funcMap[c.resourceType]
if !ok { if !ok {
return UnsupportedTypeError{resourceType: f.resourceType} return UnsupportedTypeError{resourceType: c.resourceType}
} }
gtsClient, err := client.NewClientFromFile(f.config.CredentialsFile) gtsClient, err := client.NewClientFromConfig(c.topLevelFlags.ConfigDir)
if err != nil { if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err) return fmt.Errorf("unable to create the GoToSocial client: %w", err)
} }
@ -24,23 +57,37 @@ func (f *FollowExecutor) Execute() error {
return doFunc(gtsClient) return doFunc(gtsClient)
} }
func (f *FollowExecutor) followAccount(gtsClient *client.Client) error { func (c *FollowExecutor) followAccount(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, false, f.accountName) accountID, err := getAccountID(gtsClient, false, c.accountName, c.topLevelFlags.ConfigDir)
if err != nil { if err != nil {
return fmt.Errorf("received an error while getting the account ID: %w", err) return fmt.Errorf("received an error while getting the account ID: %w", err)
} }
if c.unfollow {
return c.unfollowAccount(gtsClient, accountID)
}
form := client.FollowAccountForm{ form := client.FollowAccountForm{
AccountID: accountID, AccountID: accountID,
ShowReposts: f.showReposts, ShowReposts: c.showReposts,
Notify: f.notify, Notify: c.notify,
} }
if err := gtsClient.FollowAccount(form); err != nil { if err := gtsClient.FollowAccount(form); err != nil {
return fmt.Errorf("unable to follow the account: %w", err) return fmt.Errorf("unable to follow the account: %w", err)
} }
f.printer.PrintSuccess("Successfully sent the follow request.") fmt.Println("The follow request was sent successfully.")
return nil
}
func (c *FollowExecutor) unfollowAccount(gtsClient *client.Client, accountID string) error {
if err := gtsClient.UnfollowAccount(accountID); err != nil {
return fmt.Errorf("unable to unfollow the account: %w", err)
}
fmt.Println("Successfully unfollowed the account.")
return nil return nil
} }

View file

@ -1,35 +0,0 @@
package executor
import (
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/config"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
)
func (i *InitExecutor) Execute() error {
if err := utilities.EnsureDirectory(i.configDir); err != nil {
return fmt.Errorf("unable to ensure that the configuration directory is present: %w", err)
}
i.printer.PrintSuccess("The configuration directory is present.")
fileExists, err := config.FileExists(i.configDir)
if err != nil {
return fmt.Errorf("unable to check if the config file exists: %w", err)
}
if fileExists {
i.printer.PrintInfo("The configuration file is already present in " + i.configDir + "\n")
return nil
}
if err := config.SaveDefaultConfigToFile(i.configDir); err != nil {
return fmt.Errorf("unable to create a new configuration file in %s: %w", i.configDir, err)
}
i.printer.PrintSuccess("Successfully created a new configuration file in " + i.configDir)
return nil
}

View file

@ -1,6 +1,11 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor package executor
import ( import (
"flag"
"fmt" "fmt"
"strings" "strings"
@ -9,14 +14,35 @@ import (
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities" "codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
) )
func (l *LoginExecutor) Execute() error { type LoginExecutor struct {
var err error *flag.FlagSet
if l.instance == "" { topLevelFlags TopLevelFlags
return Error{"please specify the instance that you want to log into"} instance string
}
func NewLoginExecutor(tlf TopLevelFlags, name, summary string) *LoginExecutor {
command := LoginExecutor{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
topLevelFlags: tlf,
instance: "",
} }
instance := l.instance command.StringVar(&command.instance, flagInstance, "", "specify the instance that you want to login to.")
command.Usage = commandUsageFunc(name, summary, command.FlagSet)
return &command
}
func (c *LoginExecutor) Execute() error {
var err error
if c.instance == "" {
return FlagNotSetError{flagText: flagInstance}
}
instance := c.instance
if !strings.HasPrefix(instance, "https") || !strings.HasPrefix(instance, "http") { if !strings.HasPrefix(instance, "https") || !strings.HasPrefix(instance, "http") {
instance = "https://" + instance instance = "https://" + instance
@ -38,19 +64,23 @@ func (l *LoginExecutor) Execute() error {
consentPageURL := gtsClient.AuthCodeURL() consentPageURL := gtsClient.AuthCodeURL()
_ = utilities.OpenLink(l.config.Integrations.Browser, consentPageURL) utilities.OpenLink(consentPageURL)
var builder strings.Builder consentMessageFormat := `
You'll need to sign into your GoToSocial's consent page in order to generate the out-of-band token to continue with
the application's login process. Your browser may have opened the link to the consent page already. If not, please
copy and paste the link below to your browser:
builder.WriteString("\nYou'll need to sign into your GoToSocial's consent page in order to generate the out-of-band token to continue with the application's login process.") %s
builder.WriteString("\nYour browser may have opened the link to the consent page already. If not, please copy and paste the link below to your browser:")
builder.WriteString("\n\n" + consentPageURL)
builder.WriteString("\n\n" + "Once you have the code please copy and paste it below.")
builder.WriteString("\n" + "Out-of-band token: ")
l.printer.PrintInfo(builder.String()) Once you have the code please copy and paste it below.
`
fmt.Printf(consentMessageFormat, consentPageURL)
var code string var code string
fmt.Print("Out-of-band token: ")
if _, err := fmt.Scanln(&code); err != nil { if _, err := fmt.Scanln(&code); err != nil {
return fmt.Errorf("failed to read access code: %w", err) return fmt.Errorf("failed to read access code: %w", err)
@ -65,12 +95,12 @@ func (l *LoginExecutor) Execute() error {
return fmt.Errorf("unable to verify the credentials: %w", err) return fmt.Errorf("unable to verify the credentials: %w", err)
} }
loginName, err := config.SaveCredentials(l.config.CredentialsFile, account.Username, gtsClient.Authentication) loginName, err := config.SaveCredentials(c.topLevelFlags.ConfigDir, account.Username, gtsClient.Authentication)
if err != nil { if err != nil {
return fmt.Errorf("unable to save the authentication details: %w", err) return fmt.Errorf("unable to save the authentication details: %w", err)
} }
l.printer.PrintSuccess("You have successfully logged as " + loginName + ".") fmt.Printf("Successfully logged into %s\n", loginName)
return nil return nil
} }

View file

@ -1,91 +0,0 @@
package executor
import (
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client"
)
func (m *MuteExecutor) Execute() error {
funcMap := map[string]func(*client.Client) error{
resourceAccount: m.muteAccount,
resourceStatus: m.muteStatus,
}
doFunc, ok := funcMap[m.resourceType]
if !ok {
return UnsupportedTypeError{resourceType: m.resourceType}
}
gtsClient, err := client.NewClientFromFile(m.config.CredentialsFile)
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err)
}
return doFunc(gtsClient)
}
func (m *MuteExecutor) muteAccount(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, false, m.accountName)
if err != nil {
return fmt.Errorf("received an error while getting the account ID: %w", err)
}
form := client.MuteAccountForm{
Notifications: m.muteNotifications,
Duration: int(m.muteDuration.Duration.Seconds()),
}
if err := gtsClient.MuteAccount(accountID, form); err != nil {
return fmt.Errorf("unable to mute the account: %w", err)
}
m.printer.PrintSuccess("Successfully muted the account.")
return nil
}
func (m *MuteExecutor) muteStatus(gtsClient *client.Client) error {
if m.statusID == "" {
return MissingIDError{
resource: resourceStatus,
action: "mute",
}
}
status, err := gtsClient.GetStatus(m.statusID)
if err != nil {
return fmt.Errorf("unable to retrieve the status: %w", err)
}
myAccountID, err := getAccountID(gtsClient, true, nil)
if err != nil {
return fmt.Errorf("unable to get your account ID: %w", err)
}
canMute := false
if status.Account.ID == myAccountID {
canMute = true
} else {
for _, mention := range status.Mentions {
if mention.ID == myAccountID {
canMute = true
break
}
}
}
if !canMute {
return Error{"unable to mute the status because the status does not belong to you nor are you mentioned in it"}
}
if err := gtsClient.MuteStatus(m.statusID); err != nil {
return fmt.Errorf("unable to mute the status: %w", err)
}
m.printer.PrintSuccess("Successfully muted the status.")
return nil
}

View file

@ -1,40 +0,0 @@
package executor
import (
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client"
)
func (r *RejectExecutor) Execute() error {
funcMap := map[string]func(*client.Client) error{
resourceFollowRequest: r.rejectFollowRequest,
}
doFunc, ok := funcMap[r.resourceType]
if !ok {
return UnsupportedTypeError{resourceType: r.resourceType}
}
gtsClient, err := client.NewClientFromFile(r.config.CredentialsFile)
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err)
}
return doFunc(gtsClient)
}
func (r *RejectExecutor) rejectFollowRequest(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, false, r.accountName)
if err != nil {
return fmt.Errorf("received an error while getting the account ID: %w", err)
}
if err := gtsClient.RejectFollowRequest(accountID); err != nil {
return fmt.Errorf("unable to reject the follow request: %w", err)
}
r.printer.PrintSuccess("Successfully rejected the follow request.")
return nil
}

View file

@ -1,11 +1,47 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor package executor
import ( import (
"flag"
"fmt" "fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client" "codeflow.dananglin.me.uk/apollo/enbas/internal/client"
) )
type RemoveExecutor struct {
*flag.FlagSet
topLevelFlags TopLevelFlags
resourceType string
fromResourceType string
listID string
statusID string
accountNames AccountNames
}
func NewRemoveExecutor(tlf TopLevelFlags, name, summary string) *RemoveExecutor {
emptyArr := make([]string, 0, 3)
removeExe := RemoveExecutor{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
accountNames: AccountNames(emptyArr),
topLevelFlags: tlf,
}
removeExe.StringVar(&removeExe.resourceType, flagType, "", "specify the resource type to remove (e.g. account, note)")
removeExe.StringVar(&removeExe.fromResourceType, flagFrom, "", "specify the resource type to remove from (e.g. list, account, etc)")
removeExe.StringVar(&removeExe.listID, flagListID, "", "the ID of the list to remove from")
removeExe.StringVar(&removeExe.statusID, flagStatusID, "", "the ID of the status")
removeExe.Var(&removeExe.accountNames, flagAccountName, "the name of the account to remove from the resource")
removeExe.Usage = commandUsageFunc(name, summary, removeExe.FlagSet)
return &removeExe
}
func (r *RemoveExecutor) Execute() error { func (r *RemoveExecutor) Execute() error {
if r.fromResourceType == "" { if r.fromResourceType == "" {
return FlagNotSetError{flagText: flagFrom} return FlagNotSetError{flagText: flagFrom}
@ -15,7 +51,6 @@ func (r *RemoveExecutor) Execute() error {
resourceList: r.removeFromList, resourceList: r.removeFromList,
resourceAccount: r.removeFromAccount, resourceAccount: r.removeFromAccount,
resourceBookmarks: r.removeFromBookmarks, resourceBookmarks: r.removeFromBookmarks,
resourceStatus: r.removeFromStatus,
} }
doFunc, ok := funcMap[r.fromResourceType] doFunc, ok := funcMap[r.fromResourceType]
@ -23,7 +58,7 @@ func (r *RemoveExecutor) Execute() error {
return UnsupportedTypeError{resourceType: r.fromResourceType} return UnsupportedTypeError{resourceType: r.fromResourceType}
} }
gtsClient, err := client.NewClientFromFile(r.config.CredentialsFile) gtsClient, err := client.NewClientFromConfig(r.topLevelFlags.ConfigDir)
if err != nil { if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err) return fmt.Errorf("unable to create the GoToSocial client: %w", err)
} }
@ -39,8 +74,8 @@ func (r *RemoveExecutor) removeFromList(gtsClient *client.Client) error {
doFunc, ok := funcMap[r.resourceType] doFunc, ok := funcMap[r.resourceType]
if !ok { if !ok {
return UnsupportedRemoveOperationError{ return UnsupportedRemoveOperationError{
resourceType: r.resourceType, ResourceType: r.resourceType,
removeFromResourceType: r.fromResourceType, RemoveFromResourceType: r.fromResourceType,
} }
} }
@ -49,32 +84,29 @@ func (r *RemoveExecutor) removeFromList(gtsClient *client.Client) error {
func (r *RemoveExecutor) removeAccountsFromList(gtsClient *client.Client) error { func (r *RemoveExecutor) removeAccountsFromList(gtsClient *client.Client) error {
if r.listID == "" { if r.listID == "" {
return MissingIDError{ return FlagNotSetError{flagText: flagListID}
resource: resourceList,
action: "remove from",
}
} }
if r.accountNames.Empty() { if len(r.accountNames) == 0 {
return NoAccountSpecifiedError{} return NoAccountSpecifiedError{}
} }
accounts, err := getOtherAccounts(gtsClient, r.accountNames) accountIDs := make([]string, len(r.accountNames))
for ind := range r.accountNames {
accountID, err := getTheirAccountID(gtsClient, r.accountNames[ind])
if err != nil { if err != nil {
return fmt.Errorf("unable to get the accounts: %w", err) return fmt.Errorf("unable to get the account ID for %s: %w", r.accountNames[ind], err)
} }
accountIDs := make([]string, len(accounts)) accountIDs[ind] = accountID
for ind := range accounts {
accountIDs[ind] = accounts[ind].ID
} }
if err := gtsClient.RemoveAccountsFromList(r.listID, accountIDs); err != nil { if err := gtsClient.RemoveAccountsFromList(r.listID, accountIDs); err != nil {
return fmt.Errorf("unable to remove the accounts from the list: %w", err) return fmt.Errorf("unable to remove the accounts from the list: %w", err)
} }
r.printer.PrintSuccess("Successfully removed the account(s) from the list.") fmt.Println("Successfully removed the account(s) from the list.")
return nil return nil
} }
@ -87,8 +119,8 @@ func (r *RemoveExecutor) removeFromAccount(gtsClient *client.Client) error {
doFunc, ok := funcMap[r.resourceType] doFunc, ok := funcMap[r.resourceType]
if !ok { if !ok {
return UnsupportedRemoveOperationError{ return UnsupportedRemoveOperationError{
resourceType: r.resourceType, ResourceType: r.resourceType,
removeFromResourceType: r.fromResourceType, RemoveFromResourceType: r.fromResourceType,
} }
} }
@ -96,7 +128,11 @@ func (r *RemoveExecutor) removeFromAccount(gtsClient *client.Client) error {
} }
func (r *RemoveExecutor) removeNoteFromAccount(gtsClient *client.Client) error { func (r *RemoveExecutor) removeNoteFromAccount(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, false, r.accountNames) if len(r.accountNames) != 1 {
return fmt.Errorf("unexpected number of accounts specified: want 1, got %d", len(r.accountNames))
}
accountID, err := getAccountID(gtsClient, false, r.accountNames[0], r.topLevelFlags.ConfigDir)
if err != nil { if err != nil {
return fmt.Errorf("received an error while getting the account ID: %w", err) return fmt.Errorf("received an error while getting the account ID: %w", err)
} }
@ -105,7 +141,7 @@ func (r *RemoveExecutor) removeNoteFromAccount(gtsClient *client.Client) error {
return fmt.Errorf("unable to remove the private note from the account: %w", err) return fmt.Errorf("unable to remove the private note from the account: %w", err)
} }
r.printer.PrintSuccess("Successfully removed the private note from the account.") fmt.Println("Successfully removed the private note from the account.")
return nil return nil
} }
@ -118,8 +154,8 @@ func (r *RemoveExecutor) removeFromBookmarks(gtsClient *client.Client) error {
doFunc, ok := funcMap[r.resourceType] doFunc, ok := funcMap[r.resourceType]
if !ok { if !ok {
return UnsupportedRemoveOperationError{ return UnsupportedRemoveOperationError{
resourceType: r.resourceType, ResourceType: r.resourceType,
removeFromResourceType: r.fromResourceType, RemoveFromResourceType: r.fromResourceType,
} }
} }
@ -128,62 +164,14 @@ func (r *RemoveExecutor) removeFromBookmarks(gtsClient *client.Client) error {
func (r *RemoveExecutor) removeStatusFromBookmarks(gtsClient *client.Client) error { func (r *RemoveExecutor) removeStatusFromBookmarks(gtsClient *client.Client) error {
if r.statusID == "" { if r.statusID == "" {
return MissingIDError{ return FlagNotSetError{flagText: flagStatusID}
resource: resourceStatus,
action: "remove",
}
} }
if err := gtsClient.RemoveStatusFromBookmarks(r.statusID); err != nil { if err := gtsClient.RemoveStatusFromBookmarks(r.statusID); err != nil {
return fmt.Errorf("unable to remove the status from your bookmarks: %w", err) return fmt.Errorf("unable to remove the status from your bookmarks: %w", err)
} }
r.printer.PrintSuccess("Successfully removed the status from your bookmarks.") fmt.Println("Successfully removed the status from your bookmarks.")
return nil
}
func (r *RemoveExecutor) removeFromStatus(gtsClient *client.Client) error {
if r.statusID == "" {
return MissingIDError{
resource: resourceStatus,
action: "remove from",
}
}
funcMap := map[string]func(*client.Client) error{
resourceStar: r.removeStarFromStatus,
resourceLike: r.removeStarFromStatus,
resourceBoost: r.removeBoostFromStatus,
}
doFunc, ok := funcMap[r.resourceType]
if !ok {
return UnsupportedRemoveOperationError{
resourceType: r.resourceType,
removeFromResourceType: r.fromResourceType,
}
}
return doFunc(gtsClient)
}
func (r *RemoveExecutor) removeStarFromStatus(gtsClient *client.Client) error {
if err := gtsClient.UnlikeStatus(r.statusID); err != nil {
return fmt.Errorf("unable to remove the %s from the status: %w", r.resourceType, err)
}
r.printer.PrintSuccess("Successfully removed the " + r.resourceType + " from the status.")
return nil
}
func (r *RemoveExecutor) removeBoostFromStatus(gtsClient *client.Client) error {
if err := gtsClient.UnreblogStatus(r.statusID); err != nil {
return fmt.Errorf("unable to remove the boost from the status: %w", err)
}
r.printer.PrintSuccess("Successfully removed the boost from the status.")
return nil return nil
} }

View file

@ -1,14 +1,57 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor package executor
import ( import (
"flag"
"fmt" "fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client" "codeflow.dananglin.me.uk/apollo/enbas/internal/client"
"codeflow.dananglin.me.uk/apollo/enbas/internal/media"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model" "codeflow.dananglin.me.uk/apollo/enbas/internal/model"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities" "codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
) )
type ShowExecutor struct {
*flag.FlagSet
topLevelFlags TopLevelFlags
myAccount bool
skipAccountRelationship bool
showUserPreferences bool
showInBrowser bool
resourceType string
accountName string
statusID string
timelineCategory string
listID string
tag string
limit int
}
func NewShowExecutor(tlf TopLevelFlags, name, summary string) *ShowExecutor {
showExe := ShowExecutor{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
topLevelFlags: tlf,
}
showExe.BoolVar(&showExe.myAccount, flagMyAccount, false, "set to true to lookup your account")
showExe.BoolVar(&showExe.skipAccountRelationship, flagSkipRelationship, false, "set to true to skip showing your relationship to the specified account")
showExe.BoolVar(&showExe.showUserPreferences, flagShowPreferences, false, "show your preferences")
showExe.BoolVar(&showExe.showInBrowser, flagBrowser, false, "set to true to view in the browser")
showExe.StringVar(&showExe.resourceType, flagType, "", "specify the type of resource to display")
showExe.StringVar(&showExe.accountName, flagAccountName, "", "specify the account name in full (username@domain)")
showExe.StringVar(&showExe.statusID, flagStatusID, "", "specify the ID of the status to display")
showExe.StringVar(&showExe.timelineCategory, flagTimelineCategory, model.TimelineCategoryHome, "specify the timeline category to view")
showExe.StringVar(&showExe.listID, flagListID, "", "specify the ID of the list to display")
showExe.StringVar(&showExe.tag, flagTag, "", "specify the name of the tag to use")
showExe.IntVar(&showExe.limit, flagLimit, 20, "specify the limit of items to display")
showExe.Usage = commandUsageFunc(name, summary, showExe.FlagSet)
return &showExe
}
func (s *ShowExecutor) Execute() error { func (s *ShowExecutor) Execute() error {
if s.resourceType == "" { if s.resourceType == "" {
return FlagNotSetError{flagText: flagType} return FlagNotSetError{flagText: flagType}
@ -24,12 +67,7 @@ func (s *ShowExecutor) Execute() error {
resourceFollowing: s.showFollowing, resourceFollowing: s.showFollowing,
resourceBlocked: s.showBlocked, resourceBlocked: s.showBlocked,
resourceBookmarks: s.showBookmarks, resourceBookmarks: s.showBookmarks,
resourceLiked: s.showLiked, resourceNotification: s.showNotifications,
resourceStarred: s.showLiked,
resourceFollowRequest: s.showFollowRequests,
resourceMutedAccounts: s.showMutedAccounts,
resourceMedia: s.showMedia,
resourceMediaAttachment: s.showMediaAttachment,
} }
doFunc, ok := funcMap[s.resourceType] doFunc, ok := funcMap[s.resourceType]
@ -37,7 +75,7 @@ func (s *ShowExecutor) Execute() error {
return UnsupportedTypeError{resourceType: s.resourceType} return UnsupportedTypeError{resourceType: s.resourceType}
} }
gtsClient, err := client.NewClientFromFile(s.config.CredentialsFile) gtsClient, err := client.NewClientFromConfig(s.topLevelFlags.ConfigDir)
if err != nil { if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err) return fmt.Errorf("unable to create the GoToSocial client: %w", err)
} }
@ -51,77 +89,65 @@ func (s *ShowExecutor) showInstance(gtsClient *client.Client) error {
return fmt.Errorf("unable to retrieve the instance details: %w", err) return fmt.Errorf("unable to retrieve the instance details: %w", err)
} }
s.printer.PrintInstance(instance) utilities.Display(instance, *s.topLevelFlags.NoColor)
return nil return nil
} }
func (s *ShowExecutor) showAccount(gtsClient *client.Client) error { func (s *ShowExecutor) showAccount(gtsClient *client.Client) error {
account, err := getAccount(gtsClient, s.myAccount, s.accountName) var (
account model.Account
err error
)
if s.myAccount {
account, err = getMyAccount(gtsClient, s.topLevelFlags.ConfigDir)
if err != nil { if err != nil {
return fmt.Errorf("unable to get the account information: %w", err) return fmt.Errorf("received an error while getting the account details: %w", err)
}
} else {
if s.accountName == "" {
return FlagNotSetError{flagText: flagAccountName}
}
account, err = getAccount(gtsClient, s.accountName)
if err != nil {
return fmt.Errorf("received an error while getting the account details: %w", err)
}
} }
if s.showInBrowser { if s.showInBrowser {
if err := utilities.OpenLink(s.config.Integrations.Browser, account.URL); err != nil { utilities.OpenLink(account.URL)
return fmt.Errorf("unable to open link: %w", err)
}
return nil return nil
} }
var ( utilities.Display(account, *s.topLevelFlags.NoColor)
relationship *model.AccountRelationship
preferences *model.Preferences
statuses *model.StatusList
myAccountID string
)
if !s.myAccount && !s.skipAccountRelationship { if !s.myAccount && !s.skipAccountRelationship {
relationship, err = gtsClient.GetAccountRelationship(account.ID) relationship, err := gtsClient.GetAccountRelationship(account.ID)
if err != nil { if err != nil {
return fmt.Errorf("unable to retrieve the relationship to this account: %w", err) return fmt.Errorf("unable to retrieve the relationship to this account: %w", err)
} }
utilities.Display(relationship, *s.topLevelFlags.NoColor)
} }
if s.myAccount { if s.myAccount && s.showUserPreferences {
myAccountID = account.ID preferences, err := gtsClient.GetUserPreferences()
if s.showUserPreferences {
preferences, err = gtsClient.GetUserPreferences()
if err != nil { if err != nil {
return fmt.Errorf("unable to retrieve the user preferences: %w", err) return fmt.Errorf("unable to retrieve the user preferences: %w", err)
} }
}
}
if s.showStatuses { utilities.Display(preferences, *s.topLevelFlags.NoColor)
form := client.GetAccountStatusesForm{
AccountID: account.ID,
Limit: s.limit,
ExcludeReplies: s.excludeReplies,
ExcludeReblogs: s.excludeBoosts,
Pinned: s.onlyPinned,
OnlyMedia: s.onlyMedia,
OnlyPublic: s.onlyPublic,
} }
statuses, err = gtsClient.GetAccountStatuses(form)
if err != nil {
return fmt.Errorf("unable to retrieve the account's statuses: %w", err)
}
}
s.printer.PrintAccount(account, relationship, preferences, statuses, myAccountID)
return nil return nil
} }
func (s *ShowExecutor) showStatus(gtsClient *client.Client) error { func (s *ShowExecutor) showStatus(gtsClient *client.Client) error {
if s.statusID == "" { if s.statusID == "" {
return MissingIDError{ return FlagNotSetError{flagText: flagStatusID}
resource: resourceStatus,
action: "view",
}
} }
status, err := gtsClient.GetStatus(s.statusID) status, err := gtsClient.GetStatus(s.statusID)
@ -130,19 +156,12 @@ func (s *ShowExecutor) showStatus(gtsClient *client.Client) error {
} }
if s.showInBrowser { if s.showInBrowser {
if err := utilities.OpenLink(s.config.Integrations.Browser, status.URL); err != nil { utilities.OpenLink(status.URL)
return fmt.Errorf("unable to open link: %w", err)
}
return nil return nil
} }
myAccountID, err := getAccountID(gtsClient, true, nil) utilities.Display(status, *s.topLevelFlags.NoColor)
if err != nil {
return fmt.Errorf("unable to get your account ID: %w", err)
}
s.printer.PrintStatus(status, myAccountID)
return nil return nil
} }
@ -160,10 +179,7 @@ func (s *ShowExecutor) showTimeline(gtsClient *client.Client) error {
timeline, err = gtsClient.GetPublicTimeline(s.limit) timeline, err = gtsClient.GetPublicTimeline(s.limit)
case model.TimelineCategoryList: case model.TimelineCategoryList:
if s.listID == "" { if s.listID == "" {
return MissingIDError{ return FlagNotSetError{flagText: flagListID}
resource: resourceList,
action: "view the timeline in",
}
} }
var list model.List var list model.List
@ -176,7 +192,7 @@ func (s *ShowExecutor) showTimeline(gtsClient *client.Client) error {
timeline, err = gtsClient.GetListTimeline(list.ID, list.Title, s.limit) timeline, err = gtsClient.GetListTimeline(list.ID, list.Title, s.limit)
case model.TimelineCategoryTag: case model.TimelineCategoryTag:
if s.tag == "" { if s.tag == "" {
return Error{"please provide the name of the tag"} return FlagNotSetError{flagText: flagTag}
} }
timeline, err = gtsClient.GetTagTimeline(s.tag, s.limit) timeline, err = gtsClient.GetTagTimeline(s.tag, s.limit)
@ -189,17 +205,12 @@ func (s *ShowExecutor) showTimeline(gtsClient *client.Client) error {
} }
if len(timeline.Statuses) == 0 { if len(timeline.Statuses) == 0 {
s.printer.PrintInfo("There are no statuses in this timeline.\n") fmt.Println("There are no statuses in this timeline.")
return nil return nil
} }
myAccountID, err := getAccountID(gtsClient, true, nil) utilities.Display(timeline, *s.topLevelFlags.NoColor)
if err != nil {
return fmt.Errorf("unable to get your account ID: %w", err)
}
s.printer.PrintStatusList(timeline, myAccountID)
return nil return nil
} }
@ -228,7 +239,7 @@ func (s *ShowExecutor) showList(gtsClient *client.Client) error {
list.Accounts = accountMap list.Accounts = accountMap
} }
s.printer.PrintList(list) utilities.Display(list, *s.topLevelFlags.NoColor)
return nil return nil
} }
@ -240,38 +251,18 @@ func (s *ShowExecutor) showLists(gtsClient *client.Client) error {
} }
if len(lists) == 0 { if len(lists) == 0 {
s.printer.PrintInfo("You have no lists.\n") fmt.Println("You have no lists.")
return nil return nil
} }
s.printer.PrintLists(lists) utilities.Display(lists, *s.topLevelFlags.NoColor)
return nil return nil
} }
func (s *ShowExecutor) showFollowers(gtsClient *client.Client) error { func (s *ShowExecutor) showFollowers(gtsClient *client.Client) error {
if s.fromResourceType == "" { accountID, err := getAccountID(gtsClient, s.myAccount, s.accountName, s.topLevelFlags.ConfigDir)
return FlagNotSetError{flagText: flagFrom}
}
funcMap := map[string]func(*client.Client) error{
resourceAccount: s.showFollowersFromAccount,
}
doFunc, ok := funcMap[s.fromResourceType]
if !ok {
return UnsupportedShowOperationError{
resourceType: s.resourceType,
showFromResourceType: s.fromResourceType,
}
}
return doFunc(gtsClient)
}
func (s *ShowExecutor) showFollowersFromAccount(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, s.myAccount, s.accountName)
if err != nil { if err != nil {
return fmt.Errorf("received an error while getting the account ID: %w", err) return fmt.Errorf("received an error while getting the account ID: %w", err)
} }
@ -282,36 +273,16 @@ func (s *ShowExecutor) showFollowersFromAccount(gtsClient *client.Client) error
} }
if len(followers.Accounts) > 0 { if len(followers.Accounts) > 0 {
s.printer.PrintAccountList(followers) utilities.Display(followers, *s.topLevelFlags.NoColor)
} else { } else {
s.printer.PrintInfo("There are no followers for this account (or the list is hidden).\n") fmt.Println("There are no followers for this account or the list is hidden.")
} }
return nil return nil
} }
func (s *ShowExecutor) showFollowing(gtsClient *client.Client) error { func (s *ShowExecutor) showFollowing(gtsClient *client.Client) error {
if s.fromResourceType == "" { accountID, err := getAccountID(gtsClient, s.myAccount, s.accountName, s.topLevelFlags.ConfigDir)
return FlagNotSetError{flagText: flagFrom}
}
funcMap := map[string]func(*client.Client) error{
resourceAccount: s.showFollowingFromAccount,
}
doFunc, ok := funcMap[s.fromResourceType]
if !ok {
return UnsupportedShowOperationError{
resourceType: s.resourceType,
showFromResourceType: s.fromResourceType,
}
}
return doFunc(gtsClient)
}
func (s *ShowExecutor) showFollowingFromAccount(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, s.myAccount, s.accountName)
if err != nil { if err != nil {
return fmt.Errorf("received an error while getting the account ID: %w", err) return fmt.Errorf("received an error while getting the account ID: %w", err)
} }
@ -322,9 +293,9 @@ func (s *ShowExecutor) showFollowingFromAccount(gtsClient *client.Client) error
} }
if len(following.Accounts) > 0 { if len(following.Accounts) > 0 {
s.printer.PrintAccountList(following) utilities.Display(following, *s.topLevelFlags.NoColor)
} else { } else {
s.printer.PrintInfo("This account is not following anyone or the list is hidden.\n") fmt.Println("This account is not following anyone or the list is hidden.")
} }
return nil return nil
@ -337,9 +308,9 @@ func (s *ShowExecutor) showBlocked(gtsClient *client.Client) error {
} }
if len(blocked.Accounts) > 0 { if len(blocked.Accounts) > 0 {
s.printer.PrintAccountList(blocked) utilities.Display(blocked, *s.topLevelFlags.NoColor)
} else { } else {
s.printer.PrintInfo("You have no blocked accounts.\n") fmt.Println("You have no blocked accounts.")
} }
return nil return nil
@ -352,153 +323,22 @@ func (s *ShowExecutor) showBookmarks(gtsClient *client.Client) error {
} }
if len(bookmarks.Statuses) > 0 { if len(bookmarks.Statuses) > 0 {
myAccountID, err := getAccountID(gtsClient, true, nil) utilities.Display(bookmarks, *s.topLevelFlags.NoColor)
if err != nil {
return fmt.Errorf("unable to get your account ID: %w", err)
}
s.printer.PrintStatusList(bookmarks, myAccountID)
} else { } else {
s.printer.PrintInfo("You have no bookmarks.\n") fmt.Println("You have no bookmarks.")
} }
return nil return nil
} }
func (s *ShowExecutor) showLiked(gtsClient *client.Client) error { func (s *ShowExecutor) showNotifications(gts *client.Client) error {
liked, err := gtsClient.GetLikedStatuses(s.limit, s.resourceType) notifications, err := gts.GetNotifications(s.limit)
if err != nil { if err != nil {
return fmt.Errorf("unable to retrieve the list of your %s statuses: %w", s.resourceType, err) return fmt.Errorf("unable to retrieve your notifications: %w", err)
} }
if len(liked.Statuses) > 0 { for i := range notifications {
myAccountID, err := getAccountID(gtsClient, true, nil) utilities.Display(notifications[i], *s.topLevelFlags.NoColor)
if err != nil {
return fmt.Errorf("unable to get your account ID: %w", err)
}
s.printer.PrintStatusList(liked, myAccountID)
} else {
s.printer.PrintInfo("You have no " + s.resourceType + " statuses.\n")
}
return nil
}
func (s *ShowExecutor) showFollowRequests(gtsClient *client.Client) error {
accounts, err := gtsClient.GetFollowRequests(s.limit)
if err != nil {
return fmt.Errorf("unable to retrieve the list of follow requests: %w", err)
}
if len(accounts.Accounts) > 0 {
s.printer.PrintAccountList(accounts)
} else {
s.printer.PrintInfo("You have no follow requests.\n")
}
return nil
}
func (s *ShowExecutor) showMutedAccounts(gtsClient *client.Client) error {
muted, err := gtsClient.GetMutedAccounts(s.limit)
if err != nil {
return fmt.Errorf("unable to retrieve the list of muted accounts: %w", err)
}
if len(muted.Accounts) > 0 {
s.printer.PrintAccountList(muted)
} else {
s.printer.PrintInfo("You have not muted any accounts.\n")
}
return nil
}
func (s *ShowExecutor) showMediaAttachment(gtsClient *client.Client) error {
if len(s.attachmentIDs) != 1 {
return fmt.Errorf(
"unexpected number of attachment IDs received: want 1, got %d",
len(s.attachmentIDs),
)
}
attachment, err := gtsClient.GetMediaAttachment(s.attachmentIDs[0])
if err != nil {
return fmt.Errorf("unable to retrieve the media attachment: %w", err)
}
s.printer.PrintMediaAttachment(attachment)
return nil
}
func (s *ShowExecutor) showMedia(gtsClient *client.Client) error {
if s.fromResourceType == "" {
return FlagNotSetError{flagText: flagFrom}
}
funcMap := map[string]func(*client.Client) error{
resourceStatus: s.showMediaFromStatus,
}
doFunc, ok := funcMap[s.fromResourceType]
if !ok {
return UnsupportedShowOperationError{
resourceType: s.resourceType,
showFromResourceType: s.fromResourceType,
}
}
return doFunc(gtsClient)
}
func (s *ShowExecutor) showMediaFromStatus(gtsClient *client.Client) error {
if s.statusID == "" {
return MissingIDError{
resource: resourceStatus,
action: "view the media from",
}
}
status, err := gtsClient.GetStatus(s.statusID)
if err != nil {
return fmt.Errorf("unable to retrieve the status: %w", err)
}
cacheDir, err := utilities.CalculateMediaCacheDir(s.config.CacheDirectory, gtsClient.Authentication.Instance)
if err != nil {
return fmt.Errorf("unable to get the media cache directory: %w", err)
}
if err := utilities.EnsureDirectory(cacheDir); err != nil {
return fmt.Errorf("unable to ensure the existence of the directory %q: %w", cacheDir, err)
}
mediaBundle := media.NewBundle(
cacheDir,
status.MediaAttachments,
s.getAllImages,
s.getAllVideos,
s.attachmentIDs,
)
if err := mediaBundle.Download(gtsClient); err != nil {
return fmt.Errorf("unable to download the media bundle: %w", err)
}
imageFiles := mediaBundle.ImageFiles()
if len(imageFiles) > 0 {
if err := utilities.OpenMedia(s.config.Integrations.ImageViewer, imageFiles); err != nil {
return fmt.Errorf("unable to open the image viewer: %w", err)
}
}
videoFiles := mediaBundle.VideoFiles()
if len(videoFiles) > 0 {
if err := utilities.OpenMedia(s.config.Integrations.VideoPlayer, videoFiles); err != nil {
return fmt.Errorf("unable to open the video player: %w", err)
}
} }
return nil return nil

View file

@ -1,38 +1,61 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor package executor
import ( import (
"flag"
"fmt" "fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/config" "codeflow.dananglin.me.uk/apollo/enbas/internal/config"
) )
type SwitchExecutor struct {
*flag.FlagSet
topLevelFlags TopLevelFlags
toResourceType string
accountName string
}
func NewSwitchExecutor(tlf TopLevelFlags, name, summary string) *SwitchExecutor {
switchExe := SwitchExecutor{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
topLevelFlags: tlf,
}
switchExe.StringVar(&switchExe.toResourceType, flagTo, "", "the account to switch to")
switchExe.StringVar(&switchExe.accountName, flagAccountName, "", "the name of the account to switch to")
switchExe.Usage = commandUsageFunc(name, summary, switchExe.FlagSet)
return &switchExe
}
func (s *SwitchExecutor) Execute() error { func (s *SwitchExecutor) Execute() error {
funcMap := map[string]func() error{ funcMap := map[string]func() error{
resourceAccount: s.switchToAccount, resourceAccount: s.switchToAccount,
} }
doFunc, ok := funcMap[s.to] doFunc, ok := funcMap[s.toResourceType]
if !ok { if !ok {
return UnsupportedTypeError{resourceType: s.to} return UnsupportedTypeError{resourceType: s.toResourceType}
} }
return doFunc() return doFunc()
} }
func (s *SwitchExecutor) switchToAccount() error { func (s *SwitchExecutor) switchToAccount() error {
expectedNumAccountNames := 1 if s.accountName == "" {
if !s.accountName.ExpectedLength(expectedNumAccountNames) { return NoAccountSpecifiedError{}
return fmt.Errorf(
"found an unexpected number of --account-name flags: expected %d",
expectedNumAccountNames,
)
} }
if err := config.UpdateCurrentAccount(s.accountName[0], s.config.CredentialsFile); err != nil { if err := config.UpdateCurrentAccount(s.accountName, s.topLevelFlags.ConfigDir); err != nil {
return fmt.Errorf("unable to switch account to the account: %w", err) return fmt.Errorf("unable to switch account to the account: %w", err)
} }
s.printer.PrintSuccess("The current account is now set to '" + s.accountName[0] + "'.") fmt.Printf("The current account is now set to %q.\n", s.accountName)
return nil return nil
} }

View file

@ -1,40 +0,0 @@
package executor
import (
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client"
)
func (b *UnblockExecutor) Execute() error {
funcMap := map[string]func(*client.Client) error{
resourceAccount: b.unblockAccount,
}
doFunc, ok := funcMap[b.resourceType]
if !ok {
return UnsupportedTypeError{resourceType: b.resourceType}
}
gtsClient, err := client.NewClientFromFile(b.config.CredentialsFile)
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err)
}
return doFunc(gtsClient)
}
func (b *UnblockExecutor) unblockAccount(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, false, b.accountName)
if err != nil {
return fmt.Errorf("received an error while getting the account ID: %w", err)
}
if err := gtsClient.UnblockAccount(accountID); err != nil {
return fmt.Errorf("unable to unblock the account: %w", err)
}
b.printer.PrintSuccess("Successfully unblocked the account.")
return nil
}

View file

@ -1,40 +0,0 @@
package executor
import (
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client"
)
func (f *UnfollowExecutor) Execute() error {
funcMap := map[string]func(*client.Client) error{
resourceAccount: f.unfollowAccount,
}
doFunc, ok := funcMap[f.resourceType]
if !ok {
return UnsupportedTypeError{resourceType: f.resourceType}
}
gtsClient, err := client.NewClientFromFile(f.config.CredentialsFile)
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err)
}
return doFunc(gtsClient)
}
func (f *UnfollowExecutor) unfollowAccount(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, false, f.accountName)
if err != nil {
return fmt.Errorf("received an error while getting the account ID: %w", err)
}
if err := gtsClient.UnfollowAccount(accountID); err != nil {
return fmt.Errorf("unable to unfollow the account: %w", err)
}
f.printer.PrintSuccess("Successfully unfollowed the account.")
return nil
}

View file

@ -1,86 +0,0 @@
package executor
import (
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client"
)
func (m *UnmuteExecutor) Execute() error {
funcMap := map[string]func(*client.Client) error{
resourceAccount: m.unmuteAccount,
resourceStatus: m.unmuteStatus,
}
doFunc, ok := funcMap[m.resourceType]
if !ok {
return UnsupportedTypeError{resourceType: m.resourceType}
}
gtsClient, err := client.NewClientFromFile(m.config.CredentialsFile)
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err)
}
return doFunc(gtsClient)
}
func (m *UnmuteExecutor) unmuteAccount(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, false, m.accountName)
if err != nil {
return fmt.Errorf("received an error while getting the account ID: %w", err)
}
if err := gtsClient.UnmuteAccount(accountID); err != nil {
return fmt.Errorf("unable to unmute the account: %w", err)
}
m.printer.PrintSuccess("Successfully unmuted the account.")
return nil
}
func (m *UnmuteExecutor) unmuteStatus(gtsClient *client.Client) error {
if m.statusID == "" {
return MissingIDError{
resource: resourceStatus,
action: "unmute",
}
}
status, err := gtsClient.GetStatus(m.statusID)
if err != nil {
return fmt.Errorf("unable to retrieve the status: %w", err)
}
myAccountID, err := getAccountID(gtsClient, true, nil)
if err != nil {
return fmt.Errorf("unable to get your account ID: %w", err)
}
canUnmute := false
if status.Account.ID == myAccountID {
canUnmute = true
} else {
for _, mention := range status.Mentions {
if mention.ID == myAccountID {
canUnmute = true
break
}
}
}
if !canUnmute {
return Error{"unable to unmute the status because the status does not belong to you nor are you mentioned in it"}
}
if err := gtsClient.UnmuteStatus(m.statusID); err != nil {
return fmt.Errorf("unable to unmute the status: %w", err)
}
m.printer.PrintSuccess("Successfully unmuted the status.")
return nil
}

View file

@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor
import (
"flag"
"fmt"
"strings"
)
// commandUsageFunc returns the function used to print a command's help page.
func commandUsageFunc(name, summary string, flagset *flag.FlagSet) func() {
return func() {
var builder strings.Builder
fmt.Fprintf(
&builder,
"SUMMARY:\n %s - %s\n\nUSAGE:\n enbas %s [flags]\n\nFLAGS:",
name,
summary,
name,
)
flagset.VisitAll(func(f *flag.Flag) {
fmt.Fprintf(
&builder,
"\n --%s\n %s",
f.Name,
f.Usage,
)
})
builder.WriteString("\n")
w := flag.CommandLine.Output()
fmt.Fprint(w, builder.String())
}
}

View file

@ -1,7 +1,59 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor package executor
func (v *VersionExecutor) Execute() error { import (
v.printer.PrintVersion(v.full) "flag"
"fmt"
"os"
"strings"
)
type VersionExecutor struct {
*flag.FlagSet
showFullVersion bool
binaryVersion string
buildTime string
goVersion string
gitCommit string
}
func NewVersionExecutor(name, summary, binaryVersion, buildTime, goVersion, gitCommit string) *VersionExecutor {
command := VersionExecutor{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
binaryVersion: binaryVersion,
buildTime: buildTime,
goVersion: goVersion,
gitCommit: gitCommit,
showFullVersion: false,
}
command.BoolVar(&command.showFullVersion, "full", false, "prints the full build information")
command.Usage = commandUsageFunc(name, summary, command.FlagSet)
return &command
}
func (c *VersionExecutor) Execute() error {
var builder strings.Builder
if c.showFullVersion {
fmt.Fprintf(
&builder,
"Enbas\n Version: %s\n Git commit: %s\n Go version: %s\n Build date: %s\n",
c.binaryVersion,
c.gitCommit,
c.goVersion,
c.buildTime,
)
} else {
fmt.Fprintln(&builder, c.binaryVersion)
}
fmt.Fprint(os.Stdout, builder.String())
return nil return nil
} }

View file

@ -1,18 +1,40 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor package executor
import ( import (
"flag"
"fmt" "fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/config" "codeflow.dananglin.me.uk/apollo/enbas/internal/config"
) )
func (e *WhoamiExecutor) Execute() error { type WhoAmIExecutor struct {
config, err := config.NewCredentialsConfigFromFile(e.config.CredentialsFile) *flag.FlagSet
topLevelFlags TopLevelFlags
}
func NewWhoAmIExecutor(tlf TopLevelFlags, name, summary string) *WhoAmIExecutor {
whoExe := WhoAmIExecutor{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
topLevelFlags: tlf,
}
whoExe.Usage = commandUsageFunc(name, summary, whoExe.FlagSet)
return &whoExe
}
func (c *WhoAmIExecutor) Execute() error {
config, err := config.NewCredentialsConfigFromFile(c.topLevelFlags.ConfigDir)
if err != nil { if err != nil {
return fmt.Errorf("unable to load the credential config: %w", err) return fmt.Errorf("unable to load the credential config: %w", err)
} }
e.printer.PrintInfo("You are logged in as '" + config.CurrentAccount + "'.\n") fmt.Printf("You are logged in as %q.\n", config.CurrentAccount)
return nil return nil
} }

View file

@ -1,38 +0,0 @@
package flag
import (
"fmt"
"strconv"
)
type BoolPtrValue struct {
Value *bool
}
func NewBoolPtrValue() BoolPtrValue {
return BoolPtrValue{
Value: nil,
}
}
func (b BoolPtrValue) String() string {
if b.Value == nil {
return "NOT SET"
}
return strconv.FormatBool(*b.Value)
}
func (b *BoolPtrValue) Set(value string) error {
boolVar, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("unable to parse %q as a boolean value: %w", value, err)
}
b.Value = new(bool)
*b.Value = boolVar
return nil
}
func (b *BoolPtrValue) IsBoolFlag() bool { return true }

View file

@ -1,100 +0,0 @@
package flag_test
import (
"flag"
"slices"
"testing"
internalFlag "codeflow.dananglin.me.uk/apollo/enbas/internal/flag"
)
func TestBoolPtrValue(t *testing.T) {
tests := []struct {
input string
want string
}{
{
input: "True",
want: "true",
},
{
input: "true",
want: "true",
},
{
input: "1",
want: "true",
},
{
input: "False",
want: "false",
},
{
input: "false",
want: "false",
},
{
input: "0",
want: "false",
},
}
for _, test := range slices.All(tests) {
args := []string{"--boolean-value=" + test.input}
t.Run("Flag parsing test: "+test.input, testBoolPtrValueParsing(args, test.want))
}
}
func testBoolPtrValueParsing(args []string, want string) func(t *testing.T) {
return func(t *testing.T) {
flagset := flag.NewFlagSet("test", flag.ExitOnError)
boolVal := internalFlag.NewBoolPtrValue()
flagset.Var(&boolVal, "boolean-value", "Boolean value")
if err := flagset.Parse(args); err != nil {
t.Fatalf("Received an error parsing the flag: %v", err)
}
got := boolVal.String()
if got != want {
t.Errorf(
"Unexpected boolean value found after parsing BoolPtrValue: want %s, got %s",
want,
got,
)
} else {
t.Logf(
"Expected boolean value found after parsing BoolPtrValue: got %s",
got,
)
}
}
}
func TestNotSetBoolPtrValue(t *testing.T) {
flagset := flag.NewFlagSet("test", flag.ExitOnError)
boolVal := internalFlag.NewBoolPtrValue()
var otherVal string
flagset.Var(&boolVal, "boolean-value", "Boolean value")
flagset.StringVar(&otherVal, "other-value", "", "Another value")
args := []string{"--other-value", "other-value"}
if err := flagset.Parse(args); err != nil {
t.Fatalf("Received an error parsing the flag: %v", err)
}
want := "NOT SET"
got := boolVal.String()
if got != want {
t.Errorf("Unexpected string returned from the nil value; want %s, got %s", want, got)
} else {
t.Logf("Expected string returned from the nil value; got %s", got)
}
}

View file

@ -1,49 +0,0 @@
package flag
import (
"fmt"
"strconv"
"strings"
)
type IntSliceValue []int
func NewIntSliceValue() IntSliceValue {
arr := make([]int, 0, 3)
return IntSliceValue(arr)
}
func (v IntSliceValue) String() string {
var builder strings.Builder
for ind, value := range v {
if ind == len(v)-1 {
builder.WriteString(strconv.Itoa(value))
} else {
builder.WriteString(strconv.Itoa(value))
builder.WriteString(", ")
}
}
return builder.String()
}
func (v *IntSliceValue) Set(text string) error {
value, err := strconv.Atoi(text)
if err != nil {
return fmt.Errorf("unable to parse the value to an integer: %w", err)
}
*v = append(*v, value)
return nil
}
func (v IntSliceValue) Empty() bool {
return len(v) == 0
}
func (v IntSliceValue) ExpectedLength(expectedLength int) bool {
return len(v) == expectedLength
}

View file

@ -1,56 +0,0 @@
package flag_test
import (
"flag"
"testing"
internalFlag "codeflow.dananglin.me.uk/apollo/enbas/internal/flag"
)
func TestIntSliceValue(t *testing.T) {
flagset := flag.NewFlagSet("test", flag.ExitOnError)
intSliceVal := internalFlag.NewIntSliceValue()
if !intSliceVal.Empty() {
t.Fatalf("The initialised IntSliceValue is not empty")
}
flagset.Var(&intSliceVal, "int-value", "Integer value")
args := []string{
"--int-value", "0",
"--int-value", "1",
"--int-value", "2",
"--int-value", "3",
"--int-value", "4",
}
if err := flagset.Parse(args); err != nil {
t.Fatalf("Received an error parsing the flag: %v", err)
}
wantLength := 5
if !intSliceVal.ExpectedLength(wantLength) {
t.Fatalf(
"Error: intSliceVal.ExpectedLength(%d) == false: actual length is %d",
wantLength,
len(intSliceVal),
)
}
want := "0, 1, 2, 3, 4"
got := intSliceVal.String()
if got != want {
t.Errorf(
"Unexpected result after parsing IntSliceValue: want %s, got %s",
want,
got,
)
} else {
t.Logf(
"Expected result after parsing IntSliceValue: got %s",
got,
)
}
}

View file

@ -1,31 +0,0 @@
package flag
import "strings"
type StringSliceValue []string
func NewStringSliceValue() StringSliceValue {
arr := make([]string, 0, 3)
return StringSliceValue(arr)
}
func (v StringSliceValue) String() string {
return strings.Join(v, ", ")
}
func (v *StringSliceValue) Set(value string) error {
if len(value) > 0 {
*v = append(*v, value)
}
return nil
}
func (v StringSliceValue) Empty() bool {
return len(v) == 0
}
func (v StringSliceValue) ExpectedLength(expectedLength int) bool {
return len(v) == expectedLength
}

View file

@ -1,57 +0,0 @@
package flag_test
import (
"flag"
"testing"
internalFlag "codeflow.dananglin.me.uk/apollo/enbas/internal/flag"
)
func TestStringSliceValue(t *testing.T) {
flagset := flag.NewFlagSet("test", flag.ExitOnError)
stringSliceVal := internalFlag.NewStringSliceValue()
if !stringSliceVal.Empty() {
t.Fatalf("The initialised StringSliceValue is not empty")
}
flagset.Var(&stringSliceVal, "colour", "String value")
args := []string{
"--colour", "orange",
"--colour", "blue",
"--colour", "magenta",
"--colour", "red",
"--colour", "green",
"--colour", "silver",
}
if err := flagset.Parse(args); err != nil {
t.Fatalf("Received an error parsing the flag: %v", err)
}
wantLength := 6
if !stringSliceVal.ExpectedLength(wantLength) {
t.Fatalf(
"Error: intSliceVal.ExpectedLength(%d) == false: actual length is %d",
wantLength,
len(stringSliceVal),
)
}
want := "orange, blue, magenta, red, green, silver"
got := stringSliceVal.String()
if got != want {
t.Errorf(
"Unexpected result after parsing StringSliceValue: want %s, got %s",
want,
got,
)
} else {
t.Logf(
"Expected result after parsing StringSliceValue: got %s",
got,
)
}
}

View file

@ -1,79 +0,0 @@
package flag
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
const timeDurationRegexPattern string = `[0-9]{1,4}\s+(days?|hours?|minutes?|seconds?)`
type TimeDurationValue struct {
Duration time.Duration
}
func NewTimeDurationValue() TimeDurationValue {
return TimeDurationValue{
Duration: time.Duration(0),
}
}
func (v TimeDurationValue) String() string {
return v.Duration.String()
}
func (v *TimeDurationValue) Set(value string) error {
pattern := regexp.MustCompile(timeDurationRegexPattern)
matches := pattern.FindAllString(value, -1)
days, hours, minutes, seconds := 0, 0, 0, 0
var err error
for ind := range len(matches) {
switch {
case strings.Contains(matches[ind], "day"):
days, err = parseInt(matches[ind])
if err != nil {
return fmt.Errorf("unable to parse the number of days from %s: %w", matches[ind], err)
}
case strings.Contains(matches[ind], "hour"):
hours, err = parseInt(matches[ind])
if err != nil {
return fmt.Errorf("unable to parse the number of hours from %s: %w", matches[ind], err)
}
case strings.Contains(matches[ind], "minute"):
minutes, err = parseInt(matches[ind])
if err != nil {
return fmt.Errorf("unable to parse the number of minutes from %s: %w", matches[ind], err)
}
case strings.Contains(matches[ind], "second"):
seconds, err = parseInt(matches[ind])
if err != nil {
return fmt.Errorf("unable to parse the number of seconds from %s: %w", matches[ind], err)
}
}
}
durationValue := (days * 86400) + (hours * 3600) + (minutes * 60) + seconds
v.Duration = time.Duration(durationValue) * time.Second
return nil
}
func parseInt(text string) (int, error) {
split := strings.SplitN(text, " ", 2)
if len(split) != 2 {
return 0, fmt.Errorf("unexpected number of split for %s: want 2, got %d", text, len(split))
}
output, err := strconv.Atoi(split[0])
if err != nil {
return 0, fmt.Errorf("unable to convert %s to an integer: %w", text, err)
}
return output, nil
}

View file

@ -1,67 +0,0 @@
package flag_test
import (
"flag"
"slices"
"testing"
internalFlag "codeflow.dananglin.me.uk/apollo/enbas/internal/flag"
)
func TestTimeDurationValue(t *testing.T) {
parsingTests := []struct {
input string
want string
}{
{
input: `"1 day"`,
want: "24h0m0s",
},
{
input: `"3 days, 5 hours, 39 minutes and 6 seconds"`,
want: "77h39m6s",
},
{
input: `"1 minute and 30 seconds"`,
want: "1m30s",
},
{
input: `"(7 seconds) (21 hours) (41 days)"`,
want: "1005h0m7s",
},
}
for _, test := range slices.All(parsingTests) {
args := []string{"--duration", test.input}
t.Run("Flag parsing test: "+test.input, testTimeDurationValueParsing(args, test.want))
}
}
func testTimeDurationValueParsing(args []string, want string) func(t *testing.T) {
return func(t *testing.T) {
flagset := flag.NewFlagSet("test", flag.ExitOnError)
duration := internalFlag.NewTimeDurationValue()
flagset.Var(&duration, "duration", "Duration value")
if err := flagset.Parse(args); err != nil {
t.Fatalf("Received an error parsing the flag: %v", err)
}
got := duration.String()
if got != want {
t.Errorf(
"Unexpected duration parsed from the flag: want %s, got %s",
want,
got,
)
} else {
t.Logf(
"Expected duration parsed from the flag: got %s",
got,
)
}
}
}

View file

@ -1,13 +0,0 @@
package info
const (
ApplicationName string = "enbas"
ApplicationWebsite string = "https://codeflow.dananglin.me.uk/apollo/enbas"
)
var (
BinaryVersion string //nolint:gochecknoglobals
BuildTime string //nolint:gochecknoglobals
GoVersion string //nolint:gochecknoglobals
GitCommit string //nolint:gochecknoglobals
)

12
internal/internal.go Normal file
View file

@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package internal
const (
ApplicationName = "enbas"
ApplicationWebsite = "https://codeflow.dananglin.me.uk/apollo/enbas"
RedirectUri = "urn:ietf:wg:oauth:2.0:oob"
UserAgent = "Enbas/0.0.0"
)

View file

@ -1,170 +0,0 @@
package media
import (
"fmt"
"path/filepath"
"strings"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
)
const (
mediaTypeImage string = "image"
mediaTypeVideo string = "video"
)
type media struct {
source string
destination string
mediaType string
}
func (m *media) download(gtsClient *client.Client) error {
fileExists, err := utilities.FileExists(m.destination)
if err != nil {
return fmt.Errorf(
"unable to determine if %s exists: %w",
m.destination,
err,
)
}
if fileExists {
return nil
}
if err := gtsClient.DownloadMedia(m.source, m.destination); err != nil {
return fmt.Errorf(
"downloading %s -> %s failed: %w",
m.source,
m.destination,
err,
)
}
return nil
}
func newMediaHashmap(cacheDir string, attachments []model.Attachment) map[string]media {
hashmap := make(map[string]media)
for ind := range attachments {
hashmap[attachments[ind].ID] = media{
source: attachments[ind].URL,
destination: mediaFilepath(cacheDir, attachments[ind].URL),
mediaType: attachments[ind].Type,
}
}
return hashmap
}
type Bundle struct {
images []media
videos []media
}
func NewBundle(
cacheDir string,
attachments []model.Attachment,
getAllImages bool,
getAllVideos bool,
attachmentIDs []string,
) Bundle {
mediaHashmap := newMediaHashmap(cacheDir, attachments)
images := make([]media, 0)
videos := make([]media, 0)
if !getAllImages && !getAllVideos && len(attachmentIDs) == 0 {
return Bundle{
images: images,
videos: videos,
}
}
if getAllImages || getAllVideos {
if getAllImages {
for _, m := range mediaHashmap {
if m.mediaType == mediaTypeImage {
images = append(images, m)
}
}
}
if getAllVideos {
for _, m := range mediaHashmap {
if m.mediaType == mediaTypeVideo {
videos = append(videos, m)
}
}
}
return Bundle{
images: images,
videos: videos,
}
}
for _, attachmentID := range attachmentIDs {
obj, ok := mediaHashmap[attachmentID]
if !ok {
continue
}
switch obj.mediaType {
case mediaTypeImage:
images = append(images, obj)
case mediaTypeVideo:
videos = append(videos, obj)
}
}
return Bundle{
images: images,
videos: videos,
}
}
func (m *Bundle) Download(gtsClient *client.Client) error {
for ind := range m.images {
if err := m.images[ind].download(gtsClient); err != nil {
return fmt.Errorf("received an error trying to download the image files: %w", err)
}
}
for ind := range m.videos {
if err := m.videos[ind].download(gtsClient); err != nil {
return fmt.Errorf("received an error trying to download the video files: %w", err)
}
}
return nil
}
func (m *Bundle) ImageFiles() []string {
filepaths := make([]string, len(m.images))
for ind := range m.images {
filepaths[ind] = m.images[ind].destination
}
return filepaths
}
func (m *Bundle) VideoFiles() []string {
filepaths := make([]string, len(m.videos))
for ind := range m.videos {
filepaths[ind] = m.videos[ind].destination
}
return filepaths
}
func mediaFilepath(cacheDir, mediaURL string) string {
split := strings.Split(mediaURL, "/")
return filepath.Join(cacheDir, split[len(split)-1])
}

View file

@ -1,7 +1,14 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model package model
import ( import (
"fmt"
"time" "time"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
) )
type Account struct { type Account struct {
@ -56,6 +63,60 @@ type Field struct {
VerifiedAt string `json:"verified_at"` VerifiedAt string `json:"verified_at"`
} }
func (a Account) Display(noColor bool) string {
format := `
%s (@%s)
%s
%s
%s
%s
%s
%s %d
%s %d
%s %d
%s
%s
%s %s
%s
%s`
metadata := ""
for _, field := range a.Fields {
metadata += fmt.Sprintf(
"\n %s: %s",
utilities.FieldFormat(noColor, field.Name),
utilities.ConvertHTMLToText(field.Value),
)
}
return fmt.Sprintf(
format,
utilities.DisplayNameFormat(noColor, a.DisplayName),
a.Username,
utilities.HeaderFormat(noColor, "ACCOUNT ID:"),
a.ID,
utilities.HeaderFormat(noColor, "JOINED ON:"),
utilities.FormatDate(a.CreatedAt),
utilities.HeaderFormat(noColor, "STATS:"),
utilities.FieldFormat(noColor, "Followers:"), a.FollowersCount,
utilities.FieldFormat(noColor, "Following:"), a.FollowingCount,
utilities.FieldFormat(noColor, "Statuses:"), a.StatusCount,
utilities.HeaderFormat(noColor, "BIOGRAPHY:"),
utilities.WrapLines(utilities.ConvertHTMLToText(a.Note), "\n ", 80),
utilities.HeaderFormat(noColor, "METADATA:"),
metadata,
utilities.HeaderFormat(noColor, "ACCOUNT URL:"),
a.URL,
)
}
type AccountRelationship struct { type AccountRelationship struct {
ID string `json:"id"` ID string `json:"id"`
PrivateNote string `json:"note"` PrivateNote string `json:"note"`
@ -73,17 +134,97 @@ type AccountRelationship struct {
ShowingReblogs bool `json:"showing_reblogs"` ShowingReblogs bool `json:"showing_reblogs"`
} }
func (a AccountRelationship) Display(noColor bool) string {
format := `
%s
%s: %t
%s: %t
%s: %t
%s: %t
%s: %t
%s: %t
%s: %t
%s: %t
%s: %t
%s: %t
%s: %t`
privateNoteFormat := `
%s
%s`
output := fmt.Sprintf(
format,
utilities.HeaderFormat(noColor, "YOUR RELATIONSHIP WITH THIS ACCOUNT:"),
utilities.FieldFormat(noColor, "Following"), a.Following,
utilities.FieldFormat(noColor, "Is following you"), a.FollowedBy,
utilities.FieldFormat(noColor, "A follow request was sent and is pending"), a.FollowRequested,
utilities.FieldFormat(noColor, "Received a pending follow request"), a.FollowRequestedBy,
utilities.FieldFormat(noColor, "Endorsed"), a.Endorsed,
utilities.FieldFormat(noColor, "Showing Reposts (boosts)"), a.ShowingReblogs,
utilities.FieldFormat(noColor, "Muted"), a.Muting,
utilities.FieldFormat(noColor, "Notifications muted"), a.MutingNotifications,
utilities.FieldFormat(noColor, "Blocking"), a.Blocking,
utilities.FieldFormat(noColor, "Is blocking you"), a.BlockedBy,
utilities.FieldFormat(noColor, "Blocking account's domain"), a.DomainBlocking,
)
if a.PrivateNote != "" {
output += "\n"
output += fmt.Sprintf(
privateNoteFormat,
utilities.HeaderFormat(noColor, "YOUR PRIVATE NOTE ABOUT THIS ACCOUNT:"),
utilities.WrapLines(a.PrivateNote, "\n ", 80),
)
}
return output
}
type AccountListType int type AccountListType int
const ( const (
AccountListFollowers AccountListType = iota AccountListFollowers AccountListType = iota
AccountListFollowing AccountListFollowing
AccountListBlockedAccount AccountListBlockedAccount
AccountListFollowRequests
AccountListMuted
) )
type AccountList struct { type AccountList struct {
Type AccountListType Type AccountListType
Accounts []Account Accounts []Account
} }
func (a AccountList) Display(noColor bool) string {
output := "\n"
switch a.Type {
case AccountListFollowers:
output += utilities.HeaderFormat(noColor, "FOLLOWED BY:")
case AccountListFollowing:
output += utilities.HeaderFormat(noColor, "FOLLOWING:")
case AccountListBlockedAccount:
output += utilities.HeaderFormat(noColor, "BLOCKED ACCOUNTS:")
default:
output += utilities.HeaderFormat(noColor, "ACCOUNTS:")
}
if a.Type == AccountListBlockedAccount {
for i := range a.Accounts {
output += fmt.Sprintf(
"\n • %s (%s)",
a.Accounts[i].Acct,
a.Accounts[i].ID,
)
}
} else {
for i := range a.Accounts {
output += fmt.Sprintf(
"\n • %s (%s)",
utilities.DisplayNameFormat(noColor, a.Accounts[i].DisplayName),
a.Accounts[i].Acct,
)
}
}
return output
}

View file

@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model package model
type Application struct { type Application struct {
@ -5,7 +9,7 @@ type Application struct {
ClientSecret string `json:"client_secret"` ClientSecret string `json:"client_secret"`
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
RedirectURI string `json:"redirect_uri"` RedirectUri string `json:"redirect_uri"`
VapidKey string `json:"vapid_key"` VapidKey string `json:"vapid_key"`
Website string `json:"website"` Website string `json:"website"`
} }

View file

@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model package model
const ( const (

View file

@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model package model
type Emoji struct { type Emoji struct {

View file

@ -1,5 +1,15 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model package model
import (
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
)
type InstanceV2 struct { type InstanceV2 struct {
AccountDomain string `json:"account_domain"` AccountDomain string `json:"account_domain"`
Configuration InstanceConfiguration `json:"configuration"` Configuration InstanceConfiguration `json:"configuration"`
@ -106,3 +116,48 @@ type InstanceV2Usage struct {
type InstanceV2Users struct { type InstanceV2Users struct {
ActiveMonth int `json:"active_month"` ActiveMonth int `json:"active_month"`
} }
func (i InstanceV2) Display(noColor bool) string {
format := `
%s
%s
%s
%s
%s
%s
%s
%s
%s
Running GoToSocial %s
%s
%s %s
%s %s
%s %s
`
return fmt.Sprintf(
format,
utilities.HeaderFormat(noColor, "INSTANCE TITLE:"),
i.Title,
utilities.HeaderFormat(noColor, "INSTANCE DESCRIPTION:"),
utilities.WrapLines(i.DescriptionText, "\n ", 80),
utilities.HeaderFormat(noColor, "DOMAIN:"),
i.Domain,
utilities.HeaderFormat(noColor, "TERMS AND CONDITIONS:"),
utilities.WrapLines(i.TermsText, "\n ", 80),
utilities.HeaderFormat(noColor, "VERSION:"),
i.Version,
utilities.HeaderFormat(noColor, "CONTACT:"),
utilities.FieldFormat(noColor, "Name:"),
utilities.DisplayNameFormat(noColor, i.Contact.Account.DisplayName),
utilities.FieldFormat(noColor, "Username:"),
i.Contact.Account.Username,
utilities.FieldFormat(noColor, "Email:"),
i.Contact.Email,
)
}

View file

@ -1,8 +1,14 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model package model
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
) )
type ListRepliesPolicy int type ListRepliesPolicy int
@ -101,3 +107,57 @@ type List struct {
Title string `json:"title"` Title string `json:"title"`
Accounts map[string]string Accounts map[string]string
} }
func (l List) Display(noColor bool) string {
format := `
%s
%s
%s
%s
%s
%s
%s`
output := fmt.Sprintf(
format,
utilities.HeaderFormat(noColor, "LIST TITLE:"), l.Title,
utilities.HeaderFormat(noColor, "LIST ID:"), l.ID,
utilities.HeaderFormat(noColor, "REPLIES POLICY:"), l.RepliesPolicy,
utilities.HeaderFormat(noColor, "ADDED ACCOUNTS:"),
)
if len(l.Accounts) > 0 {
for acct, name := range l.Accounts {
output += fmt.Sprintf(
"\n • %s (%s)",
utilities.DisplayNameFormat(noColor, name),
acct,
)
}
} else {
output += "\n None"
}
output += "\n"
return output
}
type Lists []List
func (l Lists) Display(noColor bool) string {
output := "\n" + utilities.HeaderFormat(noColor, "LISTS")
for i := range l {
output += fmt.Sprintf(
"\n • %s (%s)",
l[i].Title,
l[i].ID,
)
}
return output
}

View file

@ -0,0 +1,127 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model
import (
"encoding/json"
"fmt"
"strings"
"time"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
)
type NotificationType int
const (
NotificationTypeFollow NotificationType = iota
NotificationTypeFollowRequest
NotificationTypeMention
NotificationTypeReblog
NotificationTypeFavourite
NotificationTypePoll
NotificationTypeStatus
NotificationTypeUnknown
)
const (
notificationFollow = "follow"
notificationFollowRequest = "follow_request"
notificationMention = "mention"
notificationReblog = "reblog"
notificationFavourite = "favourite"
notificationPoll = "poll"
notificationStatus = "status"
)
func (n NotificationType) MessageFormat() string {
mapped := map[NotificationType]string{
NotificationTypeFollow: "%s followed you",
NotificationTypeFollowRequest: "%s has requested to follow you",
NotificationTypeMention: "%s has mentioned you in this status",
NotificationTypeReblog: "%s reposted this status from you",
NotificationTypeFavourite: "%s liked this status from you",
NotificationTypePoll: "A poll from %s that you have voted in has ended",
NotificationTypeStatus: "%s has posted this status",
}
output, ok := mapped[n]
if !ok {
return "You have received a notification of an unknown type"
}
return output
}
func ParseNotificationType(value string) (NotificationType, error) {
mapped := map[string]NotificationType{
notificationFollow: NotificationTypeFollow,
notificationFollowRequest: NotificationTypeFollowRequest,
notificationMention: NotificationTypeMention,
notificationReblog: NotificationTypeReblog,
notificationFavourite: NotificationTypeFavourite,
notificationPoll: NotificationTypePoll,
notificationStatus: NotificationTypeStatus,
}
output, ok := mapped[value]
if !ok {
return NotificationTypeUnknown, UnknownNotificationError{}
}
return output, nil
}
func (n *NotificationType) UnmarshalJSON(data []byte) error {
var (
value string
err error
)
if err = json.Unmarshal(data, &value); err != nil {
return fmt.Errorf("unable to unmarshal the data: %w", err)
}
// Ignore the error if the notification type from another Fediverse service
// is not known by enbas. It will be seen as an unknown notification to the user.
*n, _ = ParseNotificationType(value)
return nil
}
type UnknownNotificationError struct{}
func (e UnknownNotificationError) Error() string {
return "unknown notification type"
}
type Notification struct {
Account *Account `json:"account"`
CreatedAt time.Time `json:"created_at"`
ID string `json:"id"`
Status *Status `json:"status"`
Type NotificationType `json:"type"`
}
func (n Notification) Display(noColor bool) string {
var builder strings.Builder
separator := "────────────────────────────────────────────────────────────────────────────────"
fmt.Fprintf(&builder,
n.Type.MessageFormat(),
utilities.DisplayNameFormat(noColor, n.Account.DisplayName)+" (@"+n.Account.Acct+")",
)
if n.Status != nil {
builder.WriteString("\n\n" + utilities.DisplayNameFormat(noColor, n.Status.Account.DisplayName) + " (@" + n.Status.Account.Acct + ")\n")
builder.WriteString(utilities.WrapLines(utilities.ConvertHTMLToText(n.Status.Content), "\n", 80) + "\n\n")
builder.WriteString(utilities.FieldFormat(noColor, "ID:") + " " + n.Status.ID + "\t" + utilities.FieldFormat(noColor, "Created at:") + " " + utilities.FormatTime(n.Status.CreatedAt) + "\n")
}
builder.WriteString(separator + "\n")
return builder.String()
}

View file

@ -1,23 +0,0 @@
package model
import (
"time"
)
type Poll struct {
Emojis []Emoji `json:"emojis"`
Expired bool `json:"expired"`
Voted bool `json:"voted"`
Multiple bool `json:"multiple"`
ExpiredAt time.Time `json:"expires_at"`
ID string `json:"id"`
OwnVotes []int `json:"own_votes"`
VotersCount int `json:"voters_count"`
VotesCount int `json:"votes_count"`
Options []PollOption `json:"options"`
}
type PollOption struct {
Title string `json:"title"`
VotesCount int `json:"votes_count"`
}

View file

@ -1,5 +1,15 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model package model
import (
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
)
type Preferences struct { type Preferences struct {
PostingDefaultVisibility string `json:"posting:default:visibility"` PostingDefaultVisibility string `json:"posting:default:visibility"`
PostingDefaultSensitive bool `json:"posting:default:sensitive"` PostingDefaultSensitive bool `json:"posting:default:sensitive"`
@ -8,3 +18,19 @@ type Preferences struct {
ReadingExpandSpoilers bool `json:"reading:expand:spoilers"` ReadingExpandSpoilers bool `json:"reading:expand:spoilers"`
ReadingAutoplayGifs bool `json:"reading:autoplay:gifs"` ReadingAutoplayGifs bool `json:"reading:autoplay:gifs"`
} }
func (p Preferences) Display(noColor bool) string {
format := `
%s
%s: %s
%s: %s
%s: %t`
return fmt.Sprintf(
format,
utilities.HeaderFormat(noColor, "YOUR PREFERENCES:"),
utilities.FieldFormat(noColor, "Default post language"), p.PostingDefaultLanguage,
utilities.FieldFormat(noColor, "Default post visibility"), p.PostingDefaultVisibility,
utilities.FieldFormat(noColor, "Mark posts as sensitive by default"), p.PostingDefaultSensitive,
)
}

View file

@ -1,7 +1,15 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model package model
import ( import (
"fmt"
"strings"
"time" "time"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
) )
type Status struct { type Status struct {
@ -22,13 +30,13 @@ type Status struct {
Mentions []Mention `json:"mentions"` Mentions []Mention `json:"mentions"`
Muted bool `json:"muted"` Muted bool `json:"muted"`
Pinned bool `json:"pinned"` Pinned bool `json:"pinned"`
Poll *Poll `json:"poll"` Poll Poll `json:"poll"`
Reblog *StatusReblogged `json:"reblog"` Reblog *StatusReblogged `json:"reblog"`
Reblogged bool `json:"reblogged"` Reblogged bool `json:"reblogged"`
ReblogsCount int `json:"reblogs_count"` ReblogsCount int `json:"reblogs_count"`
RepliesCount int `json:"replies_count"` RepliesCount int `json:"replies_count"`
Sensitive bool `json:"sensitive"` Sensitive bool `json:"sensitive"`
SpoilerText string `json:"spoiler_text"` SpolierText string `json:"spoiler_text"`
Tags []Tag `json:"tags"` Tags []Tag `json:"tags"`
Text string `json:"text"` Text string `json:"text"`
URI string `json:"uri"` URI string `json:"uri"`
@ -60,6 +68,24 @@ type Mention struct {
Username string `json:"username"` Username string `json:"username"`
} }
type Poll struct {
Emojis []Emoji `json:"emojis"`
Expired bool `json:"expired"`
Voted bool `json:"voted"`
Multiple bool `json:"multiple"`
ExpiredAt time.Time `json:"expires_at"`
ID string `json:"id"`
OwnVotes []int `json:"own_votes"`
VotersCount int `json:"voters_count"`
VotesCount int `json:"votes_count"`
Options []PollOption `json:"options"`
}
type PollOption struct {
Title string `json:"title"`
VotesCount int `json:"votes_count"`
}
type StatusReblogged struct { type StatusReblogged struct {
Account Account `json:"account"` Account Account `json:"account"`
Application Application `json:"application"` Application Application `json:"application"`
@ -78,12 +104,12 @@ type StatusReblogged struct {
Mentions []Mention `json:"mentions"` Mentions []Mention `json:"mentions"`
Muted bool `json:"muted"` Muted bool `json:"muted"`
Pinned bool `json:"pinned"` Pinned bool `json:"pinned"`
Poll *Poll `json:"poll"` Poll Poll `json:"poll"`
Reblogged bool `json:"reblogged"` Reblogged bool `json:"reblogged"`
RebloggsCount int `json:"reblogs_count"` RebloggsCount int `json:"reblogs_count"`
RepliesCount int `json:"replies_count"` RepliesCount int `json:"replies_count"`
Sensitive bool `json:"sensitive"` Sensitive bool `json:"sensitive"`
SpoilerText string `json:"spoiler_text"` SpolierText string `json:"spoiler_text"`
Tags []Tag `json:"tags"` Tags []Tag `json:"tags"`
Text string `json:"text"` Text string `json:"text"`
URI string `json:"uri"` URI string `json:"uri"`
@ -131,7 +157,93 @@ type MediaDimensions struct {
Width int `json:"width"` Width int `json:"width"`
} }
func (s Status) Display(noColor bool) string {
format := `
%s (@%s)
%s
%s
%s
%s
%s
%s
%s
Boosts: %d
Likes: %d
Replies: %d
%s
%s
%s
%s
`
return fmt.Sprintf(
format,
utilities.DisplayNameFormat(noColor, s.Account.DisplayName), s.Account.Username,
utilities.HeaderFormat(noColor, "CONTENT:"),
utilities.WrapLines(utilities.ConvertHTMLToText(s.Content), "\n ", 80),
utilities.HeaderFormat(noColor, "STATUS ID:"),
s.ID,
utilities.HeaderFormat(noColor, "CREATED AT:"),
utilities.FormatTime(s.CreatedAt),
utilities.HeaderFormat(noColor, "STATS:"),
s.ReblogsCount,
s.FavouritesCount,
s.RepliesCount,
utilities.HeaderFormat(noColor, "VISIBILITY:"),
s.Visibility,
utilities.HeaderFormat(noColor, "URL:"),
s.URL,
)
}
type StatusListType int
const (
StatusListTimeline StatusListType = iota
StatusListBookMarks
)
type StatusList struct { type StatusList struct {
Type StatusListType
Name string Name string
Statuses []Status Statuses []Status
} }
func (s StatusList) Display(noColor bool) string {
var builder strings.Builder
var name string
separator := "────────────────────────────────────────────────────────────────────────────────"
if s.Type == StatusListTimeline {
name = "TIMELINE: " + s.Name
} else {
name = s.Name
}
builder.WriteString(utilities.HeaderFormat(noColor, name) + "\n")
for _, status := range s.Statuses {
builder.WriteString("\n" + utilities.DisplayNameFormat(noColor, status.Account.DisplayName) + " (@" + status.Account.Acct + ")\n")
statusID := status.ID
createdAt := status.CreatedAt
if status.Reblog != nil {
builder.WriteString("reposted this status from " + utilities.DisplayNameFormat(noColor, status.Reblog.Account.DisplayName) + " (@" + status.Reblog.Account.Acct + ")\n")
statusID = status.Reblog.ID
createdAt = status.Reblog.CreatedAt
}
builder.WriteString(utilities.WrapLines(utilities.ConvertHTMLToText(status.Content), "\n", 80) + "\n\n")
builder.WriteString(utilities.FieldFormat(noColor, "ID:") + " " + statusID + "\t" + utilities.FieldFormat(noColor, "Created at:") + " " + utilities.FormatTime(createdAt) + "\n")
builder.WriteString(separator + "\n")
}
return builder.String()
}

View file

@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model package model
import ( import (

View file

@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model package model
import ( import (

View file

@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model package model
const ( const (

View file

@ -1,144 +0,0 @@
package printer
import (
"strconv"
"strings"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model"
)
func (p Printer) PrintAccount(
account model.Account,
relationship *model.AccountRelationship,
preferences *model.Preferences,
statuses *model.StatusList,
userAccountID string,
) {
var builder strings.Builder
builder.WriteString("\n" + p.fullDisplayNameFormat(account.DisplayName, account.Acct))
builder.WriteString("\n\n" + p.headerFormat("ACCOUNT ID:"))
builder.WriteString("\n" + account.ID)
builder.WriteString("\n\n" + p.headerFormat("JOINED ON:"))
builder.WriteString("\n" + p.formatDate(account.CreatedAt))
builder.WriteString("\n\n" + p.headerFormat("STATS:"))
builder.WriteString("\n" + p.fieldFormat("Followers:"))
builder.WriteString(" " + strconv.Itoa(account.FollowersCount))
builder.WriteString("\n" + p.fieldFormat("Following:"))
builder.WriteString(" " + strconv.Itoa(account.FollowingCount))
builder.WriteString("\n" + p.fieldFormat("Statuses:"))
builder.WriteString(" " + strconv.Itoa(account.StatusCount))
builder.WriteString("\n\n" + p.headerFormat("BIOGRAPHY:"))
builder.WriteString(p.convertHTMLToText(account.Note, true))
builder.WriteString("\n\n" + p.headerFormat("METADATA:"))
for _, field := range account.Fields {
builder.WriteString("\n" + p.fieldFormat(field.Name) + ": " + p.convertHTMLToText(field.Value, false))
}
builder.WriteString("\n\n" + p.headerFormat("ACCOUNT URL:"))
builder.WriteString("\n" + account.URL)
if relationship != nil {
builder.WriteString(p.accountRelationship(relationship))
}
if preferences != nil {
builder.WriteString(p.userPreferences(preferences))
}
if statuses != nil {
builder.WriteString("\n\n" + p.statusList(*statuses, userAccountID))
}
builder.WriteString("\n\n")
p.print(builder.String())
}
func (p Printer) accountRelationship(relationship *model.AccountRelationship) string {
var builder strings.Builder
builder.WriteString("\n\n" + p.headerFormat("YOUR RELATIONSHIP WITH THIS ACCOUNT:"))
builder.WriteString("\n" + p.fieldFormat("Following:"))
builder.WriteString(" " + strconv.FormatBool(relationship.Following))
builder.WriteString("\n" + p.fieldFormat("Is following you:"))
builder.WriteString(" " + strconv.FormatBool(relationship.FollowedBy))
builder.WriteString("\n" + p.fieldFormat("A follow request was sent and is pending:"))
builder.WriteString(" " + strconv.FormatBool(relationship.FollowRequested))
builder.WriteString("\n" + p.fieldFormat("Received a pending follow request:"))
builder.WriteString(" " + strconv.FormatBool(relationship.FollowRequestedBy))
builder.WriteString("\n" + p.fieldFormat("Endorsed:"))
builder.WriteString(" " + strconv.FormatBool(relationship.Endorsed))
builder.WriteString("\n" + p.fieldFormat("Showing Reposts (boosts):"))
builder.WriteString(" " + strconv.FormatBool(relationship.ShowingReblogs))
builder.WriteString("\n" + p.fieldFormat("Muted:"))
builder.WriteString(" " + strconv.FormatBool(relationship.Muting))
builder.WriteString("\n" + p.fieldFormat("Notifications muted:"))
builder.WriteString(" " + strconv.FormatBool(relationship.MutingNotifications))
builder.WriteString("\n" + p.fieldFormat("Blocking:"))
builder.WriteString(" " + strconv.FormatBool(relationship.Blocking))
builder.WriteString("\n" + p.fieldFormat("Is blocking you:"))
builder.WriteString(" " + strconv.FormatBool(relationship.BlockedBy))
builder.WriteString("\n" + p.fieldFormat("Blocking account's domain:"))
builder.WriteString(" " + strconv.FormatBool(relationship.DomainBlocking))
if relationship.PrivateNote != "" {
builder.WriteString("\n\n" + p.headerFormat("YOUR PRIVATE NOTE ABOUT THIS ACCOUNT:"))
builder.WriteString("\n" + p.wrapLines(relationship.PrivateNote, 0))
}
return builder.String()
}
func (p Printer) userPreferences(preferences *model.Preferences) string {
var builder strings.Builder
builder.WriteString("\n\n" + p.headerFormat("YOUR PREFERENCES:"))
builder.WriteString("\n" + p.fieldFormat("Default post language:"))
builder.WriteString(" " + preferences.PostingDefaultLanguage)
builder.WriteString("\n" + p.fieldFormat("Default post visibility:"))
builder.WriteString(" " + preferences.PostingDefaultVisibility)
builder.WriteString("\n" + p.fieldFormat("Mark posts as sensitive by default:"))
builder.WriteString(" " + strconv.FormatBool(preferences.PostingDefaultSensitive))
return builder.String()
}
func (p Printer) PrintAccountList(list model.AccountList) {
var builder strings.Builder
builder.WriteString("\n")
switch list.Type {
case model.AccountListFollowers:
builder.WriteString(p.headerFormat("Followed by:"))
case model.AccountListFollowing:
builder.WriteString(p.headerFormat("Following:"))
case model.AccountListBlockedAccount:
builder.WriteString(p.headerFormat("Blocked accounts:"))
case model.AccountListFollowRequests:
builder.WriteString(p.headerFormat("Accounts that have requested to follow you:"))
case model.AccountListMuted:
builder.WriteString(p.headerFormat("Muted accounts:"))
default:
builder.WriteString(p.headerFormat("Accounts:"))
}
if list.Type == model.AccountListBlockedAccount {
for ind := range list.Accounts {
builder.WriteString("\n" + symbolBullet + " " + list.Accounts[ind].Acct + " (" + list.Accounts[ind].ID + ")")
}
} else {
for ind := range list.Accounts {
builder.WriteString("\n" + symbolBullet + " " + p.fullDisplayNameFormat(list.Accounts[ind].DisplayName, list.Accounts[ind].Acct))
}
}
builder.WriteString("\n")
p.print(builder.String())
}

View file

@ -1,38 +0,0 @@
package printer
import (
"strings"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model"
)
func (p Printer) PrintInstance(instance model.InstanceV2) {
var builder strings.Builder
builder.WriteString("\n" + p.headerFormat("INSTANCE TITLE:"))
builder.WriteString("\n" + instance.Title)
builder.WriteString("\n\n" + p.headerFormat("INSTANCE DESCRIPTION:"))
builder.WriteString("\n" + p.wrapLines(instance.DescriptionText, 0))
builder.WriteString("\n\n" + p.headerFormat("DOMAIN:"))
builder.WriteString("\n" + instance.Domain)
builder.WriteString("\n\n" + p.headerFormat("TERMS AND CONDITIONS:"))
builder.WriteString("\n" + p.wrapLines(instance.TermsText, 2))
builder.WriteString("\n\n" + p.headerFormat("VERSION:"))
builder.WriteString("\nRunning GoToSocial " + instance.Version)
builder.WriteString("\n\n" + p.headerFormat("CONTACT:"))
builder.WriteString("\n" + p.fieldFormat("Name:"))
builder.WriteString(" " + instance.Contact.Account.DisplayName)
builder.WriteString("\n" + p.fieldFormat("Username:"))
builder.WriteString(" " + instance.Contact.Account.Acct)
builder.WriteString("\n" + p.fieldFormat("Email:"))
builder.WriteString(" " + instance.Contact.Email)
builder.WriteString("\n\n")
p.print(builder.String())
}

View file

@ -1,45 +0,0 @@
package printer
import (
"strings"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model"
)
func (p Printer) PrintList(list model.List) {
var builder strings.Builder
builder.WriteString("\n" + p.headerFormat("LIST TITLE:") + "\n")
builder.WriteString(list.Title + "\n\n")
builder.WriteString(p.headerFormat("LIST ID:") + "\n")
builder.WriteString(list.ID + "\n\n")
builder.WriteString(p.headerFormat("REPLIES POLICY:") + "\n")
builder.WriteString(list.RepliesPolicy.String() + "\n\n")
builder.WriteString(p.headerFormat("ADDED ACCOUNTS:"))
if len(list.Accounts) > 0 {
for acct, name := range list.Accounts {
builder.WriteString("\n" + symbolBullet + " " + p.fullDisplayNameFormat(name, acct))
}
} else {
builder.WriteString("\n" + "None")
}
builder.WriteString("\n")
printToStdout(builder.String())
}
func (p Printer) PrintLists(lists []model.List) {
var builder strings.Builder
builder.WriteString("\n" + p.headerFormat("LISTS"))
for i := range lists {
builder.WriteString("\n" + symbolBullet + " " + lists[i].Title + " (" + lists[i].ID + ")")
}
builder.WriteString("\n")
printToStdout(builder.String())
}

View file

@ -1,45 +0,0 @@
package printer
import (
"strconv"
"strings"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model"
)
func (p Printer) PrintMediaAttachment(attachement model.Attachment) {
var builder strings.Builder
// The ID of the media attachment
builder.WriteString("\n" + p.headerFormat("MEDIA ATTACHMENT ID:"))
builder.WriteString("\n" + attachement.ID)
// The media attachment type
builder.WriteString("\n\n" + p.headerFormat("TYPE:"))
builder.WriteString("\n" + attachement.Type)
// The description that came with the media attachment (if any)
description := attachement.Description
if description == "" {
description = noMediaDescription
}
builder.WriteString("\n\n" + p.headerFormat("DESCRIPTION:"))
builder.WriteString("\n" + description)
// The original size of the media attachment
builder.WriteString("\n\n" + p.headerFormat("ORIGINAL SIZE:"))
builder.WriteString("\n" + attachement.Meta.Original.Size)
// The media attachment's focus
builder.WriteString("\n\n" + p.headerFormat("FOCUS:"))
builder.WriteString("\n" + p.fieldFormat("x:") + " " + strconv.FormatFloat(attachement.Meta.Focus.X, 'f', 1, 64))
builder.WriteString("\n" + p.fieldFormat("y:") + " " + strconv.FormatFloat(attachement.Meta.Focus.Y, 'f', 1, 64))
// The URL to the source of the media attachment
builder.WriteString("\n\n" + p.headerFormat("URL:"))
builder.WriteString("\n" + attachement.URL)
builder.WriteString("\n\n")
p.print(builder.String())
}

View file

@ -1,196 +0,0 @@
package printer
import (
"os"
"os/exec"
"regexp"
"strings"
"time"
)
const (
minTerminalWidth = 40
noMediaDescription = "This media attachment has no description."
symbolBullet = "\u2022"
symbolPollMeter = "\u2501"
symbolCheckMark = "\u2714"
symbolFailure = "\u2717"
symbolImage = "\uf03e"
symbolLiked = "\uf51f"
symbolNotLiked = "\uf41e"
symbolBookmarked = "\uf47a"
symbolNotBookmarked = "\uf461"
symbolBoosted = "\u2BAD"
dateFormat = "02 Jan 2006"
dateTimeFormat = "02 Jan 2006, 15:04 (MST)"
)
type theme struct {
reset string
bold string
boldblue string
boldmagenta string
green string
boldgreen string
grey string
red string
boldred string
yellow string
boldyellow string
}
type Printer struct {
theme theme
noColor bool
lineWrapCharacterLimit int
pager string
statusSeparator string
}
func NewPrinter(
noColor bool,
pager string,
lineWrapCharacterLimit int,
) *Printer {
theme := theme{
reset: "\033[0m",
bold: "\033[1m",
boldblue: "\033[34;1m",
boldmagenta: "\033[35;1m",
green: "\033[32m",
boldgreen: "\033[32;1m",
grey: "\033[90m",
red: "\033[31m",
boldred: "\033[31;1m",
yellow: "\033[33m",
boldyellow: "\033[33;1m",
}
if lineWrapCharacterLimit < minTerminalWidth {
lineWrapCharacterLimit = minTerminalWidth
}
return &Printer{
theme: theme,
noColor: noColor,
lineWrapCharacterLimit: lineWrapCharacterLimit,
pager: pager,
statusSeparator: strings.Repeat("\u2501", lineWrapCharacterLimit),
}
}
func (p Printer) PrintSuccess(text string) {
success := p.theme.boldgreen + symbolCheckMark + p.theme.reset
if p.noColor {
success = symbolCheckMark
}
printToStdout(success + " " + text + "\n")
}
func (p Printer) PrintFailure(text string) {
failure := p.theme.boldred + symbolFailure + p.theme.reset
if p.noColor {
failure = symbolFailure
}
printToStderr(failure + " " + text + "\n")
}
func (p Printer) PrintInfo(text string) {
printToStdout(text)
}
func (p Printer) headerFormat(text string) string {
if p.noColor {
return text
}
return p.theme.boldblue + text + p.theme.reset
}
func (p Printer) fieldFormat(text string) string {
if p.noColor {
return text
}
return p.theme.green + text + p.theme.reset
}
func (p Printer) bold(text string) string {
if p.noColor {
return text
}
return p.theme.bold + text + p.theme.reset
}
func (p Printer) fullDisplayNameFormat(displayName, acct string) string {
// use this pattern to remove all emoji strings
pattern := regexp.MustCompile(`\s:[A-Za-z0-9_]*:`)
var builder strings.Builder
if p.noColor {
builder.WriteString(pattern.ReplaceAllString(displayName, ""))
} else {
builder.WriteString(p.theme.boldmagenta + pattern.ReplaceAllString(displayName, "") + p.theme.reset)
}
builder.WriteString(" (@" + acct + ")")
return builder.String()
}
func (p Printer) formatDate(date time.Time) string {
return date.Local().Format(dateFormat) //nolint:gosmopolitan
}
func (p Printer) formatDateTime(date time.Time) string {
return date.Local().Format(dateTimeFormat) //nolint:gosmopolitan
}
func (p Printer) print(text string) {
if p.pager == "" {
printToStdout(text)
return
}
cmdSplit := strings.Split(p.pager, " ")
pager := new(exec.Cmd)
if len(cmdSplit) == 1 {
pager = exec.Command(cmdSplit[0]) //nolint:gosec
} else {
pager = exec.Command(cmdSplit[0], cmdSplit[1:]...) //nolint:gosec
}
pipe, err := pager.StdinPipe()
if err != nil {
printToStdout(text)
return
}
pager.Stdout = os.Stdout
pager.Stderr = os.Stderr
_ = pager.Start()
defer func() {
_ = pipe.Close()
_ = pager.Wait()
}()
_, _ = pipe.Write([]byte(text))
}
func printToStdout(text string) {
os.Stdout.WriteString(text)
}
func printToStderr(text string) {
os.Stderr.WriteString(text)
}

Some files were not shown because too many files have changed in this diff Show more