Compare commits

...

87 commits

Author SHA1 Message Date
5043c2a4d1
chore: bump golang.org/x/net from 0.26.0 to 0.28.0
All checks were successful
REUSE Compliance Check / check (push) Successful in 5s
2024-08-29 19:04:11 +01:00
e3a0436027
docs: updated installation guide
All checks were successful
REUSE Compliance Check / check (push) Successful in 5s
2024-08-29 08:31:12 +01:00
fa58e5b719
ci: use remote mage-ci action
All checks were successful
Tests / test (pull_request) Successful in 17s
REUSE Compliance Check / check (push) Successful in 13s
Replace the local mage action with the remote mage-ci action.
2024-08-29 08:13:49 +01:00
e898a1ded5
fix: fixed error when using --help flag
All checks were successful
REUSE Compliance Check / check (push) Successful in 8s
Parse the flags before loading the configuration to fix the error where
using the --help flag caused the app to return an error if the
configuration file does not exist.
2024-08-23 02:47:36 +01:00
987f8caa1c
fix: update the user agent 2024-08-23 02:40:13 +01:00
cc5e3f0044
refactor: move information values to info package
- Move the build and application information to the internal info
  package.
- Move the user agent and redirect URI string to the internal client
  package.
2024-08-23 02:35:30 +01:00
89e53bcc9f
refactor: update the executor lookup table
All checks were successful
Tests / test (pull_request) Successful in 16s
REUSE Compliance Check / check (push) Successful in 6s
Updated the executor lookup table by changing the map type from
map[string]Executor to map[string]func(string, bool, []string) error in
order to reduce the cost of initialising the map.

Previously the map initialised all the executors despite only needing
to run the single executor called by the command. The map now instead
maps the command to the function of type
'func(string, bool, []string) error' that will initialise and run the
executor called by the command. As a result of the change, the
Executor interface is no longer needed and has been removed.

PR: #57
2024-08-21 18:44:04 +01:00
74f32aab53
test: add more tests for the internal flag package
All checks were successful
REUSE Compliance Check / check (push) Successful in 6s
2024-08-20 04:25:29 +01:00
0ad02e0af4
feat(BREAKING): new parser for the TDV
All checks were successful
Tests / test (pull_request) Successful in 18s
REUSE Compliance Check / check (push) Successful in 5s
This commit adds a new parser for the internal time duration flag value
(TimeDurationValue). Previously this used the parser from the time
package from the standard library but this was limited to parsing units
of time up to hours.

The new parser allows users to specify duration in days, hours, minutes,
seconds and a combination of the above. It is quite flexible in the way
users format their string input.

Additonal changes:

- Added unit tests for the command-line parsing of the
  TimeDurationValue type.
- Updated the unit tests for the BoolPtrValue type.
- Updated documentation.

PR: #55
2024-08-20 03:32:54 +01:00
b558c5adff
fix: fixed the BoolPtrValue in internal flag pkg
All checks were successful
Tests / test (pull_request) Successful in 16s
REUSE Compliance Check / check (push) Successful in 5s
- Fixed the BoolPtrValue type in the internal flag package by adding the
  IsBoolFlag() bool method to indicate to the command-line parser that
  the flag is a boolean flag.
- Added unit tests for the BoolPtrValue type.

PR: #56
2024-08-20 00:55:05 +01:00
61a00d7a5b
docs: update the getting started guide
All checks were successful
REUSE Compliance Check / check (push) Successful in 5s
2024-08-19 07:10:38 +01:00
d38431d9e8
fix: error if home config directory not found
All checks were successful
Tests / test (pull_request) Successful in 17s
REUSE Compliance Check / check (push) Successful in 6s
Summary:

If the user hasn't supplied the path to a configuration directory and
Enbas fails to find the user's default home configuration directory,
Enbas will now return an error message back to the user. Previously
Enbas would try to, instead, calculate the configuration directory
using the user's current directory.

Changes:

- Return an error if the user's home configuration directory cannot
  be found.
- Add tests to the internal.config package.
- Update the tests in internal.utilities package.

PR: #54
2024-08-18 21:13:08 +01:00
15b9761497
refactor: clean up unused const
All checks were successful
REUSE Compliance Check / check (push) Successful in 5s
2024-08-18 09:57:48 +01:00
277bec49ef
fix: add MissingIDError type
Add the MissingIDError type for when the ID of a resource is not
provided where required.
2024-08-18 08:59:44 +01:00
3c8633ff04
refactor: minimise the main package
All checks were successful
REUSE Compliance Check / check (push) Successful in 5s
Minimise the main package by moving all flag parsing to the Execute
function.
2024-08-18 07:18:57 +01:00
6e5e0c4c5a
fix(BREAKING): rename spoiler-text flag to summary
All checks were successful
REUSE Compliance Check / check (push) Successful in 5s
Rename the spoiler-text flag to summary as the text represents the
status' summary. The usage message and manual indicates that the text is
also known as the spoiler text or content warning.
2024-08-18 06:48:15 +01:00
7e4b8bb05f
fix: fixed the calculations in cache directories
All checks were successful
Tests / test (pull_request) Successful in 17s
REUSE Compliance Check / check (push) Successful in 6s
Changes:

- Fixed the issue where the instance's FQDN was not included in the
  cache directories' path when the user sets a custom path to the root
  cache directory.

- Added unit tests for the cache directory calculations.

- Updated REUSE.toml to fix compliance failures.

- Added small changes/fixes based on feedback from golangci-lint.

PR: #53
Resolves: #51
2024-08-18 00:06:18 +01:00
7fd93e8778
ci: add a workflow for tests
Some checks failed
Tests / test (pull_request) Successful in 15s
REUSE Compliance Check / check (push) Failing after 13s
- Added an action for running mage targets
- Added a workflow for running tests
- Add our first unit test

PR: #52
2024-08-17 19:15:12 +01:00
009515ddb4
[skip ci] chore: set minimum go version to 1.23.0 2024-08-17 11:45:40 +01:00
dd6c21afe8
refactor: clean up errors
All checks were successful
REUSE Compliance Check / check (push) Successful in 6s
- Created generic Error types to remove the need to import the errors
  package.
- Used the generic Error types in place of the single use custom Error
  types that have no fields.
- Created new Error types where necessary.

PR: #50
2024-08-17 11:31:06 +01:00
f223e0417c
fix: view the summary of a status
All checks were successful
REUSE Compliance Check / check (push) Successful in 6s
View the status' summary/spoiler/content warning text if it exists.
2024-08-17 03:49:17 +01:00
ee13dcde00
chore: update .golangci.yaml
All checks were successful
REUSE Compliance Check / check (push) Successful in 6s
2024-08-16 23:12:17 +01:00
39d9ef1a38
fix(BREAKING): remove the from-file flag
All checks were successful
REUSE Compliance Check / check (push) Successful in 8s
The from-file flag was used to read text from a file for creating
statuses. This flag is now removed and replaced with the file@ prefix
with the content flag.

For example, --from-file status.md is now replaced with
--content file@status.md
2024-08-16 19:57:44 +01:00
c0f1f7d03a
feat: deleting statuses
All checks were successful
REUSE Compliance Check / check (push) Successful in 11s
This commit adds support for deleting statuses.

Before sending the delete request to the instance, Enbas will first
verify that the status that the user wants to delete actually belongs to
them.

The user has the option to save the text of the deleted status. This
will be written to a text file within the cache directory.

PR: #48
Resolves: #44
2024-08-16 18:53:08 +01:00
9bcb924ac0
ci: add workflow for REUSE Compliance Check
All checks were successful
REUSE Compliance Check / check (push) Successful in 5s
For now the compliance checks will be performed whenever a change is
made on the main branch.
2024-08-16 16:07:53 +01:00
0a41516ae9
chore: update REUSE.toml 2024-08-16 15:03:16 +01:00
3037af60ed
feat: mute and unmute statuses
This commit adds support for muting and unmuting statuses. When viewing
a status the user can now see whether they've muted the status or not.
A status can only be muted by the user if they own it or are mentioned
in it.

PR: apollo/enbas#47
Resolves: apollo/enbas#46
2024-08-16 14:42:57 +01:00
42f54c6020
feat: add more support for media attachments
This commit adds more support for interacting with media attachments.
Now users can:

- Upload media to their instances and create media attachments.
- Edit existing media attachments.
- Attach one or more existing media to a new status.
- Upload and attach one or more media files to a new status.

PR: apollo/enbas#42
Resolves: apollo/enbas#29
2024-08-15 21:40:17 +01:00
4e76d20a7a
fix: strip trailing empty line when reading files
Updated the ReadFile function in 'utilities' by using the Scanner from
the bufio package to read lines from a text file. The trailing empty
line is stripped after scanning and before returning back to the caller.
2024-08-15 15:24:25 +01:00
eb016b96e9
fix(BREAKING): update poll interaction
Summary:

This commit updates and enhances poll interaction. From now on users
will interact with a poll via the status that contains it. Direct
interaction with the poll (via the poll's ID) is no longer supported.
This helps resolve an issue where it wasn't possible to find the owner
of the poll when interacting with it directly.

Changes:

- Users can no longer view a poll directly using the Poll ID.
  Instead polls can be viewed when viewing statuses or timelines.
- More details about a poll is shown in statuses and timelines.
- Votes are now added to polls via statuses.
- Poll results are hidden unless the following conditions are met.
    - The user is the owner of the poll.
    - The poll has expired.
    - The user has already voted in the poll.
- Enbas can now detect and stop a poll owner from voting in their own
  poll.
- When a status is created Enbas will now only print the ID of the
  created status instead of the whole thing.

PR: apollo/enbas#43

Resolves apollo/enbas#39
2024-08-14 11:29:30 +01:00
a0eab3b6ae
refactor: clean up the getAccountID function sig
The getAccountID function no longer needs the path to the credentials
file.
2024-08-13 23:51:16 +01:00
878a898d4c
fix: use VerifyCredentials to get account info
Use the clients VerifyCredentials method to get the user's account
information.
2024-08-13 23:41:12 +01:00
b77bbaa6e0
fix: updated internal/executor/executors.go 2024-08-13 17:18:55 +01:00
84091f398d
feat: add Enbas CLI schema and code generator
Summary:

- Created a custom CLI schema for Enbas which will act as the Source
  of Truth for code and document generation.
- Created a code generator which uses the schema to generate the
  executor definitions and code in the internal usage package.

Changes:

- Created the Enbas CLI schema as the Source of Truth for Enbas.
- Created the code generator that generates the executor
  definitions and code in the usage package.
- Regenerated the executor definitions using the code generator.
- Moved the custom flag value types to the new internal flag
  package.
- Created a new flag value type for the bool pointer to replace the
  flag.BoolFunc() used for the sensitive and no-color flags.
- Moved the version and build variables to the new internal version
  package to simplify the version executor.
- Created a new usage package and moved the usage functions there.
- Changed the type of the account-name flag from string to the
  internal StringSliceValue type.
2024-08-13 14:53:26 +01:00
299b134b58
chore: set minimum go version to 1.22.6 2024-08-09 09:31:51 +01:00
cb7ac4175d
style: ran gofmt 2024-08-02 13:04:40 +01:00
2ccfdc4336
refactor: update client package
- add a type for the parameters of the sendRequest method
- contentType is now a parameter for the sendRequest method.
2024-08-02 08:15:15 +01:00
bad22ecd70
feat: add all-images and all-videos flags
When viewing media attachments from a status, the all-images and
all-videos flags will allow users to view all images or videos,
respectively.
2024-08-01 04:24:47 +01:00
3d20adfa57
chore: REUSE.toml
- Replace .reuse/dep5 with the new REUSE.toml file.
- Add licensing information to REUSE.toml and remove the licensing
  headers from the source files.
2024-08-01 04:01:38 +01:00
60aeec06f9
feat: add all-images and all-videos flags
When viewing media attachments from a status, the all-images and
all-videos flags will allow users to view all images or videos
respectively.
2024-08-01 00:54:27 +01:00
6bc00138ab
feat: show statuses created from an account
When viewing an account's information the user can now optionally view
the statuses that the account has created.
2024-08-01 00:24:31 +01:00
c468d1fb62
refactor: create constant values for base paths
Created constant values for the Accounts and Follow Requests base API
paths.
2024-07-10 12:06:36 +01:00
6e260266b1
fix: update the status list printer function
- Show whether a status is a boost, a reply to another status or a
  normal post.
- Move the "Created At" field to a new line.
- Show the date and time when a status was boosted.
2024-07-10 09:14:13 +01:00
2bb801b6d0
feat: add ability to reply to statuses 2024-07-09 03:13:54 +01:00
ec282e207f
refactor: use baseListPath where necessary
Replace the static string of the base list API path with the
baseListPath constant.
2024-07-09 00:25:43 +01:00
28bf902599
fix: improve how lists are displayed
Improve how ordered and unordered lists are displayed within the
contents of a status by adding indentation when a list item is wrapped.

Changes:

- fix: improve how ordered and unordered lists are displayed in status
  and status list views.
- fix: improve how media attachments are displayed in status list views.
- refactor: move the line wrapping and HTML converting functions from
  utilities to the internal printer package.
- refactor: the convertHTMLToText now (optionally) applies line
  wrapping after conversion.
2024-07-08 22:53:13 +01:00
c72340490d
fix: user's boosted action in status list view
Correctly use status.Reblogged instead of status.Bookmarked value when
showing if a user has boosted a status or not in the status list view.
2024-07-07 19:15:23 +01:00
e0b4174a76
chore: add missing license annotation 2024-07-06 18:11:15 +01:00
181384fea3
docs: add the user manual
The user manual: a page that users can reference when playing with
Enbas.
2024-07-06 17:36:42 +01:00
d52bb3fdf4
fix: print error messages from instance
If an error occurs when sending a request to an instance, try and decode
and print the error message back to the user.
2024-07-04 11:27:00 +01:00
e5eb2d72a8
fix: set minimum go version to 1.22.5 2024-07-03 23:25:22 +01:00
f64f8a43a6
fix: command line format for showing followers
CHANGES:

- When viewing followers and followings from an account, the from flag
  must now be set by the user.
    - enbas show --type followers --from account --my-account
    - enbas show --type following --from account --account-name john

- New error type for unsupported show operations: Return an error if
  enbas detects an unsupported show operation (e.g. showing media from a
  list).
2024-07-03 14:20:25 +01:00
a8aeec2fbf
fix: update description for the vote flag 2024-07-03 13:34:27 +01:00
5ec1cc0e18
fix: rename the choose flag.
Rename to choose flag to vote for voting in polls.
2024-07-03 13:21:32 +01:00
1541aa6936
docs(README): fix link to installation guide 2024-07-02 13:31:14 +01:00
c12a2ce293
docs(README): update mirrored forges
Add Radicle as a forge (network) that the main branch is mirrored to.
2024-07-02 11:16:32 +01:00
4d0a42ba23
fix: mute accounts indefinitely by default 2024-07-01 21:27:04 +01:00
e4cce2cae4
refactor: update structure of switch executor 2024-07-01 21:12:20 +01:00
23ec6048b5
docs: updated documentation
- Converted documentation from Asciidoc to Markdown
- Updated the installation instructions and moved to a separate
  file in the docs directory.
- Updated the login instructions and moved to the Getting Started guide
  in the docs directory.
- Updated the licensing information in the main README.
- Created a page for changelog.
- Create a page for the user manual.
2024-07-01 13:49:11 +01:00
b03b3c31cc
fix: set absolute path to the credentials file
When creating the configuration file during the initialisation process
calculate the absolute path to the credentials file instead of the
relative path.
2024-06-29 16:02:19 +01:00
c8892a6535
feat: show user's actions on a status
Show whether the user has liked, boosted or bookmarked a status within
both the status and status list views.
2024-06-27 09:10:18 +01:00
b4cb362a7c
fix: use browser setting in config for URLs
Use the browser specified in the configuration file to open URLs. If the
browser is not specified the link will not be opened and an error is
returned to the user in most cases. This replaces the use of the BROWSER
environment variable and the xdg-open command (on linux).
2024-06-26 12:38:27 +01:00
42251f6df8
feat: add configuration support to enbas
SUMMARY

This commit adds configuration support to enbas. The configuration is
stored as a JSON file in the user specified configuration directory.

When using enbas for the first time, the user will first need to
execute the new init command in order to generate the configuration.
Once this has been generated the user can edit the settings to
personalise their experience, login to their account and use enbas as
normal.

For now the configurable settings included in the configuration
are as follows:

- The path to the credentials file (by default this is set to a file in
  the same directory as the configuration file).
- The path to the cache directory.
- The character limit used for line wrapping.
- The programs used for integrations such as paging, media viewing,
  opening URLs, etc.

CHANGES

- added the new config type.
- added the new init executor for generating a new configuration file.
- removed the following top level flags in favour of the new
  configration support.
    - cache-dir
    - pager
    - image-viewer
    - video-player
    - max-terminal-width
- added a new error type for use when an unknown media attachment ID
  is specified.
- updated the usage function for the executors to support a case
  where a flagsets has no flags.
- update .golangci.yaml to disable some linters
2024-06-25 12:39:39 +01:00
63f0526f39
fix: ensure no errors when specifying media flags
Fix the issue where users specify flags/arguments when specifying their
media players.
2024-06-25 04:45:05 +01:00
632a620180
feat: view media with external applications
This commit adds integration to external image viewers and video players
to allow users to view image and video attachments.

Enbas creates a cache directory where the media is downloaded to before
opening the external program for viewing.

Users can view one or more media attachments from a single status.
2024-06-22 01:16:24 +01:00
67d4caf4cb
fix: show media type in timeline view 2024-06-20 12:30:20 +01:00
96ad6a7b55
fix: view media attachments from reposted statuses
In the timeline view, you should now be able to see the content, poll
and media attachments from statuses that have been reposted (boosted).
2024-06-19 01:36:32 +01:00
e8114f8d22
feat: view media attachment information
View media attachment information in the timeline and status views.
Users can also show detailed information of the attachments via the
attachment ID.
2024-06-18 19:58:59 +01:00
2a386fcda5
fix: parse HTML text for account metadata 2024-06-18 15:54:08 +01:00
56445601a3
feat: add support for muting and unmuting accounts
Now that muting and unmuting accounts are now supported in GoToSocial
(as of version 0.16.0) Enbas can now allow users to do that.

Users can also see a list of accounts that they've muted.
2024-06-18 04:59:32 +01:00
48666645c7
fix: added missing line breaks in timeline view
Added missing line breaks between the status author and the status
content in the timeline view.
2024-06-17 21:59:34 +01:00
ccdd8b6530
fix: add a new internal printer
Add a new internal printer package for printing resources to the screen
or pager.

With the new printer in place, most of the settings such as the pager
command, colour theme, whether or not colour output is disabled, etc
are defined in one place which saves us the trouble of passing an
increasing number of parameters to an increasing number of Display
methods throughout the code base.

The old Displayer interface and associated Display methods in the
model package are removed as this is now handled by the printer.

The format functions in the utilities package has essentially been
rewritten as methods to the Printer type.

Additional changes:

- All indentation when displaying information about resources (e.g.
  statuses, instance, accounts) are removed.
- The application's build information now has colour output.
2024-06-17 18:59:20 +01:00
f73f1f5872
fix: check following before adding to list
Check if the user is following a specified account before adding it to a
list.
2024-06-16 20:29:16 +01:00
eb2d9f44f6
chore: add copyright header 2024-06-16 14:15:58 +01:00
792a93d736
feat: add support for polls
Add support for creating, viewing and voting in
polls.
2024-06-15 18:40:13 +01:00
ac2e74cac3
fix: improve version output
Use text/tabwriter to print the version information with aligned
columns.
2024-06-10 19:36:00 +01:00
c04cd7e8a5
refactor: update code for the executor package
Changes:

- moved the command names from main to the executor package.
- moved the unknownCommandError to the executor package and renamed it
  to UnknownCommandError.
- define the command summaries within the executor package.
- replace the switch statement in main.run() with a hashmap for looking
  up the corresponding executor to the given command.
- transformed BlockExecutor to BlockOrUnblockExecutor.
- transformed FollowExecutor to FollowOrUnfollowExecutor.

Additional fixes:

- fixed flag for the version executor.
2024-06-10 18:59:01 +01:00
ec706b43e9
fix: updated headers for account lists 2024-06-10 11:17:07 +01:00
5fb55ed2cf
feat: accept or reject follow requests
Add support to allow users to accept or reject follow requests.
2024-06-10 10:58:43 +01:00
d21f1fbf6a
chore: bump golang.org/x/net from 0.21.0 to 0.26.0 2024-06-10 10:17:02 +01:00
b48fac3341
fix: filter out emojis with underscores
When displaying the username also filter out the emojis with
underscores.
2024-06-09 17:24:07 +01:00
db16e4aa16
feat: add pager support
Add support for printing the output of timelines, long statuses, etc to
an external pager of the user's choice.
2024-06-09 15:58:10 +01:00
9c8476fa97
feat: add reblogging (boosting) support
Add support for reblogging (boosting) a status by adding a boost to said
status.
2024-06-06 06:23:26 +01:00
c8187587a8
fix: print commands in aligned columns
Use the text/tabwriter to write the commands and their summaries in
aligned columns when printing the help text.
2024-06-06 06:21:16 +01:00
64ecc90499
refactor: add FullDisplayNameFormat
Add the FullDisplayNameFormat as a replacement for DisplayNameFormat for
displaying the full display name. It uses the builder from the strings
package to reduce the use of fmt.Sprintf.
2024-06-04 20:29:19 +01:00
d452122b58
feat: add support for liking and unliking statuses
Add support for liking and un-liking statuses by allowing users to add
and remove stars/likes to them.

The user can also view a list of statuses that they've liked.
2024-06-04 17:01:33 +01:00
66c530d524
fix: fix type of PollOption.VotesCount 2024-06-04 09:54:40 +01:00
124 changed files with 8511 additions and 2251 deletions

View file

@ -0,0 +1,16 @@
---
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

@ -0,0 +1,22 @@
---
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
output:
format: colored-line-number
formats: colored-line-number
print-issues-lines: true
print-linter-name: true
uniq-by-line: true
@ -31,5 +31,9 @@ linters-settings:
linters:
enable-all: true
disable:
#- json
- execinquery
- exhaustruct
- gomnd
- mnd
- tagliatelle
fast: false

View file

@ -1,10 +0,0 @@
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: ...

View file

@ -1,256 +0,0 @@
// 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.]

43
README.md Normal file
View file

@ -0,0 +1,43 @@
# 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.

42
REUSE.toml Normal file
View file

@ -0,0 +1,42 @@
# 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

@ -1,3 +0,0 @@
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) Normal file

Binary file not shown.

162
cmd/enbas-codegen/main.go Normal file
View file

@ -0,0 +1,162 @@
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

@ -0,0 +1,72 @@
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

@ -0,0 +1,175 @@
/*
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

@ -0,0 +1,14 @@
/*
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 "" }}
}

View file

@ -1,13 +0,0 @@
// 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,207 +1,13 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"flag"
"fmt"
"os"
"strconv"
"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() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v.\n", err)
if err := executor.Execute(); err != nil {
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
}

View file

@ -1,50 +0,0 @@
// 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())
}
}

1
docs/changelog.md Normal file
View file

@ -0,0 +1 @@
# Changelog

29
docs/configuration.md Normal file
View file

@ -0,0 +1,29 @@
# 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. |

152
docs/getting_started.md Normal file
View file

@ -0,0 +1,152 @@
# 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).

118
docs/installation.md Normal file
View file

@ -0,0 +1,118 @@
# 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).

875
docs/manual.md Normal file
View file

@ -0,0 +1,875 @@
# 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_

24
docs/tips_and_tricks.md Normal file
View file

@ -0,0 +1,24 @@
# 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,9 +1,5 @@
// 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
go 1.22.0
go 1.23.0
require golang.org/x/net v0.21.0
require golang.org/x/net v0.28.0

4
go.sum
View file

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

View file

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

View file

@ -1,7 +1,3 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package client
import (
@ -13,13 +9,25 @@ import (
"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) {
path := "/api/v1/accounts/verify_credentials"
url := g.Authentication.Instance + path
url := g.Authentication.Instance + baseAccountsPath + "/verify_credentials"
var account model.Account
if err := g.sendRequest(http.MethodGet, url, nil, &account); err != nil {
params := requestParameters{
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)
}
@ -27,33 +35,53 @@ func (g *Client) VerifyCredentials() (model.Account, error) {
}
func (g *Client) GetAccount(accountURI string) (model.Account, error) {
path := "/api/v1/accounts/lookup?acct=" + accountURI
url := g.Authentication.Instance + path
url := g.Authentication.Instance + baseAccountsPath + "/lookup?acct=" + accountURI
var account model.Account
if err := g.sendRequest(http.MethodGet, url, nil, &account); err != nil {
params := requestParameters{
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 account, nil
}
func (g *Client) GetAccountRelationship(accountID string) (model.AccountRelationship, error) {
path := "/api/v1/accounts/relationships?id=" + accountID
url := g.Authentication.Instance + path
func (g *Client) GetAccountRelationship(accountID string) (*model.AccountRelationship, error) {
url := g.Authentication.Instance + baseAccountsPath + "/relationships?id=" + accountID
var relationships []model.AccountRelationship
if err := g.sendRequest(http.MethodGet, url, nil, &relationships); err != nil {
return model.AccountRelationship{}, fmt.Errorf("received an error after sending the request to get the account relationship: %w", err)
params := requestParameters{
httpMethod: http.MethodGet,
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 {
return model.AccountRelationship{}, fmt.Errorf("unexpected number of account relationships returned: want 1, got %d", len(relationships))
return nil, fmt.Errorf(
"unexpected number of account relationships returned: want 1, got %d",
len(relationships),
)
}
return relationships[0], nil
return &relationships[0], nil
}
type FollowAccountForm struct {
@ -69,9 +97,17 @@ func (g *Client) FollowAccount(form FollowAccountForm) error {
}
requestBody := bytes.NewBuffer(data)
url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/follow", form.AccountID)
url := g.Authentication.Instance + baseAccountsPath + "/" + form.AccountID + "/follow"
if err := g.sendRequest(http.MethodPost, url, requestBody, nil); err != nil {
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 follow request: %w", err)
}
@ -79,9 +115,17 @@ func (g *Client) FollowAccount(form FollowAccountForm) error {
}
func (g *Client) UnfollowAccount(accountID string) error {
url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/unfollow", accountID)
url := g.Authentication.Instance + baseAccountsPath + "/" + accountID + "/unfollow"
if err := g.sendRequest(http.MethodPost, url, nil, nil); err != nil {
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 unfollow the account: %w", err)
}
@ -89,11 +133,19 @@ func (g *Client) UnfollowAccount(accountID string) error {
}
func (g *Client) GetFollowers(accountID string, limit int) (model.AccountList, error) {
url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/followers?limit=%d", accountID, limit)
url := g.Authentication.Instance + fmt.Sprintf("%s/%s/followers?limit=%d", baseAccountsPath, accountID, limit)
accounts := make([]model.Account, limit)
if err := g.sendRequest(http.MethodGet, url, nil, &accounts); err != nil {
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 followers: %w", err)
}
@ -106,11 +158,19 @@ func (g *Client) GetFollowers(accountID string, limit int) (model.AccountList, e
}
func (g *Client) GetFollowing(accountID string, limit int) (model.AccountList, error) {
url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/following?limit=%d", accountID, limit)
url := g.Authentication.Instance + fmt.Sprintf("%s/%s/following?limit=%d", baseAccountsPath, accountID, limit)
accounts := make([]model.Account, limit)
if err := g.sendRequest(http.MethodGet, url, nil, &accounts); err != nil {
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 followed accounts: %w", err)
}
@ -123,9 +183,17 @@ func (g *Client) GetFollowing(accountID string, limit int) (model.AccountList, e
}
func (g *Client) BlockAccount(accountID string) error {
url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/block", accountID)
url := g.Authentication.Instance + baseAccountsPath + "/" + accountID + "/block"
if err := g.sendRequest(http.MethodPost, url, nil, nil); err != nil {
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 block the account: %w", err)
}
@ -133,9 +201,17 @@ func (g *Client) BlockAccount(accountID string) error {
}
func (g *Client) UnblockAccount(accountID string) error {
url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/unblock", accountID)
url := g.Authentication.Instance + baseAccountsPath + "/" + accountID + "/unblock"
if err := g.sendRequest(http.MethodPost, url, nil, nil); err != nil {
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 unblock the account: %w", err)
}
@ -145,9 +221,17 @@ func (g *Client) UnblockAccount(accountID string) error {
func (g *Client) GetBlockedAccounts(limit int) (model.AccountList, error) {
url := g.Authentication.Instance + fmt.Sprintf("/api/v1/blocks?limit=%d", limit)
accounts := make([]model.Account, limit)
var accounts []model.Account
if err := g.sendRequest(http.MethodGet, url, nil, &accounts); err != nil {
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 blocked accounts: %w", err)
}
@ -172,11 +256,197 @@ func (g *Client) SetPrivateNote(accountID, note string) error {
}
requestBody := bytes.NewBuffer(data)
url := g.Authentication.Instance + fmt.Sprintf("/api/v1/accounts/%s/note", accountID)
url := g.Authentication.Instance + baseAccountsPath + "/" + accountID + "/note"
if err := g.sendRequest(http.MethodPost, url, requestBody, nil); err != nil {
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 set the private note: %w", err)
}
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,7 +1,3 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package client
import (
@ -11,12 +7,18 @@ import (
"io"
"net/http"
"net/url"
"os"
"time"
"codeflow.dananglin.me.uk/apollo/enbas/internal"
"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 {
Authentication config.Credentials
HTTPClient http.Client
@ -24,8 +26,8 @@ type Client struct {
Timeout time.Duration
}
func NewClientFromConfig(configDir string) (*Client, error) {
config, err := config.NewCredentialsConfigFromFile(configDir)
func NewClientFromFile(path string) (*Client, error) {
config, err := config.NewCredentialsConfigFromFile(path)
if err != nil {
return nil, fmt.Errorf("unable to get the authentication configuration: %w", err)
}
@ -41,7 +43,7 @@ func NewClient(authentication config.Credentials) *Client {
gtsClient := Client{
Authentication: authentication,
HTTPClient: httpClient,
UserAgent: internal.UserAgent,
UserAgent: userAgent,
Timeout: 5 * time.Second,
}
@ -50,7 +52,7 @@ func NewClient(authentication config.Credentials) *Client {
func (g *Client) AuthCodeURL() string {
format := "%s/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code"
escapedRedirectURI := url.QueryEscape(internal.RedirectUri)
escapedRedirectURI := url.QueryEscape(redirectURI)
return fmt.Sprintf(
format,
@ -60,17 +62,64 @@ func (g *Client) AuthCodeURL() string {
)
}
func (g *Client) sendRequest(method string, url string, requestBody io.Reader, object any) error {
func (g *Client) DownloadMedia(url, path string) 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)
defer cancel()
request, err := http.NewRequestWithContext(ctx, method, url, requestBody)
request, err := http.NewRequestWithContext(ctx, params.httpMethod, params.url, params.requestBody)
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)
}
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("Accept", "application/json; charset=utf-8")
if params.contentType != "" {
request.Header.Set("Content-Type", params.contentType)
}
request.Header.Set("Accept", applicationJSON)
request.Header.Set("User-Agent", g.UserAgent)
if len(g.Authentication.AccessToken) > 0 {
@ -85,17 +134,32 @@ func (g *Client) sendRequest(method string, url string, requestBody io.Reader, o
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusBadRequest {
return fmt.Errorf(
"did not receive an OK response from the GoToSocial server: got %d",
response.StatusCode,
)
message := struct {
Error string `json:"error"`
}{
Error: "",
}
if err := json.NewDecoder(response.Body).Decode(&message); err != nil {
return ResponseError{
StatusCode: response.StatusCode,
Message: "",
MessageDecodeErr: err,
}
}
return ResponseError{
StatusCode: response.StatusCode,
Message: message.Error,
MessageDecodeErr: nil,
}
}
if object == nil {
if params.output == nil {
return nil
}
if err := json.NewDecoder(response.Body).Decode(object); err != nil {
if err := json.NewDecoder(response.Body).Decode(params.output); err != nil {
return fmt.Errorf(
"unable to decode the response from the GoToSocial server: %w",
err,

51
internal/client/errors.go Normal file
View file

@ -0,0 +1,51 @@
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,7 +1,3 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package client
import (
@ -17,7 +13,15 @@ func (g *Client) GetInstance() (model.InstanceV2, error) {
var instance model.InstanceV2
if err := g.sendRequest(http.MethodGet, url, nil, &instance); err != nil {
params := requestParameters{
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)
}

View file

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

159
internal/client/media.go Normal file
View file

@ -0,0 +1,159 @@
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
}

65
internal/client/poll.go Normal file
View file

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

View file

@ -1,7 +1,3 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package client
import (
@ -13,13 +9,25 @@ import (
"codeflow.dananglin.me.uk/apollo/enbas/internal/model"
)
const (
baseStatusesPath string = "/api/v1/statuses"
)
func (g *Client) GetStatus(statusID string) (model.Status, error) {
path := "/api/v1/statuses/" + statusID
path := baseStatusesPath + "/" + statusID
url := g.Authentication.Instance + path
var status model.Status
if err := g.sendRequest(http.MethodGet, url, nil, &status); err != nil {
params := requestParameters{
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &status,
}
if err := g.sendRequest(params); err != nil {
return model.Status{}, fmt.Errorf(
"received an error after sending the request to get the status information: %w",
err,
@ -30,16 +38,26 @@ func (g *Client) GetStatus(statusID string) (model.Status, error) {
}
type CreateStatusForm struct {
Content string `json:"status"`
Language string `json:"language"`
SpoilerText string `json:"spoiler_text"`
Boostable bool `json:"boostable"`
Federated bool `json:"federated"`
Likeable bool `json:"likeable"`
Replyable bool `json:"replyable"`
Sensitive bool `json:"sensitive"`
ContentType model.StatusContentType `json:"content_type"`
Visibility model.StatusVisibility `json:"visibility"`
Content string `json:"status"`
InReplyTo string `json:"in_reply_to_id"`
Language string `json:"language"`
SpoilerText string `json:"spoiler_text"`
Boostable bool `json:"boostable"`
Federated bool `json:"federated"`
Likeable bool `json:"likeable"`
Replyable bool `json:"replyable"`
Sensitive bool `json:"sensitive"`
Poll *CreateStatusPollForm `json:"poll,omitempty"`
ContentType model.StatusContentType `json:"content_type"`
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) {
@ -49,11 +67,19 @@ func (g *Client) CreateStatus(form CreateStatusForm) (model.Status, error) {
}
requestBody := bytes.NewBuffer(data)
url := g.Authentication.Instance + "/api/v1/statuses"
url := g.Authentication.Instance + baseStatusesPath
var status model.Status
if err := g.sendRequest(http.MethodPost, url, requestBody, &status); err != nil {
params := requestParameters{
httpMethod: http.MethodPost,
url: url,
requestBody: requestBody,
contentType: applicationJSON,
output: &status,
}
if err := g.sendRequest(params); err != nil {
return model.Status{}, fmt.Errorf(
"received an error after sending the request to create the status: %w",
err,
@ -68,12 +94,19 @@ func (g *Client) GetBookmarks(limit int) (model.StatusList, error) {
url := g.Authentication.Instance + path
bookmarks := model.StatusList{
Type: model.StatusListBookMarks,
Name: "BOOKMARKS",
Name: "Your Bookmarks",
Statuses: nil,
}
if err := g.sendRequest(http.MethodGet, url, nil, &bookmarks.Statuses); err != nil {
params := requestParameters{
httpMethod: http.MethodGet,
url: url,
requestBody: nil,
contentType: "",
output: &bookmarks.Statuses,
}
if err := g.sendRequest(params); err != nil {
return bookmarks, fmt.Errorf(
"received an error after sending the request to get the bookmarks: %w",
err,
@ -87,7 +120,15 @@ func (g *Client) AddStatusToBookmarks(statusID string) error {
path := fmt.Sprintf("/api/v1/statuses/%s/bookmark", statusID)
url := g.Authentication.Instance + path
if err := g.sendRequest(http.MethodPost, url, nil, nil); err != nil {
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 add the status to the list of bookmarks: %w",
err,
@ -101,7 +142,15 @@ func (g *Client) RemoveStatusFromBookmarks(statusID string) error {
path := fmt.Sprintf("/api/v1/statuses/%s/unbookmark", statusID)
url := g.Authentication.Instance + path
if err := g.sendRequest(http.MethodPost, url, nil, nil); err != nil {
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 remove the status from the list of bookmarks: %w",
err,
@ -110,3 +159,178 @@ func (g *Client) RemoveStatusFromBookmarks(statusID string) error {
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,7 +1,3 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package client
import (
@ -15,8 +11,7 @@ func (g *Client) GetHomeTimeline(limit int) (model.StatusList, error) {
path := fmt.Sprintf("/api/v1/timelines/home?limit=%d", limit)
timeline := model.StatusList{
Type: model.StatusListTimeline,
Name: "HOME",
Name: "Timeline: Home",
Statuses: nil,
}
@ -27,8 +22,7 @@ func (g *Client) GetPublicTimeline(limit int) (model.StatusList, error) {
path := fmt.Sprintf("/api/v1/timelines/public?limit=%d", limit)
timeline := model.StatusList{
Type: model.StatusListTimeline,
Name: "PUBLIC",
Name: "Timeline: Public",
Statuses: nil,
}
@ -39,8 +33,7 @@ func (g *Client) GetListTimeline(listID, title string, limit int) (model.StatusL
path := fmt.Sprintf("/api/v1/timelines/list/%s?limit=%d", listID, limit)
timeline := model.StatusList{
Type: model.StatusListTimeline,
Name: "LIST (" + title + ")",
Name: "Timeline: List (" + title + ")",
Statuses: nil,
}
@ -51,8 +44,7 @@ func (g *Client) GetTagTimeline(tag string, limit int) (model.StatusList, error)
path := fmt.Sprintf("/api/v1/timelines/tag/%s?limit=%d", tag, limit)
timeline := model.StatusList{
Type: model.StatusListTimeline,
Name: "TAG (" + tag + ")",
Name: "Timeline: Tag (" + tag + ")",
Statuses: nil,
}
@ -64,7 +56,15 @@ func (g *Client) getTimeline(path string, timeline model.StatusList) (model.Stat
var statuses []model.Status
if err := g.sendRequest(http.MethodGet, url, nil, &statuses); err != nil {
params := requestParameters{
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)
}

View file

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

128
internal/config/config.go Normal file
View file

@ -0,0 +1,128 @@
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

@ -0,0 +1,83 @@
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,7 +1,3 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package config
import (
@ -10,11 +6,12 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
)
const (
credentialsFileName = "credentials.json"
defaultCredentialsFileName = "credentials.json"
)
type CredentialsConfig struct {
@ -40,35 +37,35 @@ func (e CredentialsNotFoundError) Error() string {
// 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
// is not present, it will be created.
func SaveCredentials(configDir, username string, credentials Credentials) (string, error) {
if err := ensureConfigDir(calculateConfigDir(configDir)); err != nil {
func SaveCredentials(filePath, username string, credentials Credentials) (string, error) {
part := filepath.Dir(filePath)
// 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)
}
var authConfig CredentialsConfig
filepath := credentialsConfigFile(configDir)
if _, err := os.Stat(filepath); err != nil {
if _, err := os.Stat(filePath); err != nil {
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)
} else {
authConfig, err = NewCredentialsConfigFromFile(configDir)
authConfig, err = NewCredentialsConfigFromFile(filePath)
if err != nil {
return "", fmt.Errorf("unable to retrieve the existing authentication configuration: %w", err)
}
}
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://")
}
instance := utilities.GetFQDN(credentials.Instance)
authenticationName := username + "@" + instance
@ -76,15 +73,15 @@ func SaveCredentials(configDir, username string, credentials Credentials) (strin
authConfig.Credentials[authenticationName] = credentials
if err := saveCredentialsConfigFile(authConfig, configDir); err != nil {
if err := saveCredentialsConfigFile(authConfig, filePath); err != nil {
return "", fmt.Errorf("unable to save the authentication configuration to file: %w", err)
}
return authenticationName, nil
}
func UpdateCurrentAccount(account string, configDir string) error {
credentialsConfig, err := NewCredentialsConfigFromFile(configDir)
func UpdateCurrentAccount(account string, filePath string) error {
credentialsConfig, err := NewCredentialsConfigFromFile(filePath)
if err != nil {
return fmt.Errorf("unable to retrieve the existing authentication configuration: %w", err)
}
@ -95,19 +92,19 @@ func UpdateCurrentAccount(account string, configDir string) error {
credentialsConfig.CurrentAccount = account
if err := saveCredentialsConfigFile(credentialsConfig, configDir); err != nil {
if err := saveCredentialsConfigFile(credentialsConfig, filePath); err != nil {
return fmt.Errorf("unable to save the authentication configuration to file: %w", err)
}
return nil
}
func NewCredentialsConfigFromFile(configDir string) (CredentialsConfig, error) {
path := credentialsConfigFile(configDir)
file, err := os.Open(path)
// NewCredentialsConfigFromFile creates a new CredentialsConfig value from reading
// the credentials file.
func NewCredentialsConfigFromFile(filePath string) (CredentialsConfig, error) {
file, err := os.Open(filePath)
if err != nil {
return CredentialsConfig{}, fmt.Errorf("unable to open %s, %w", path, err)
return CredentialsConfig{}, fmt.Errorf("unable to open %s: %w", filePath, err)
}
defer file.Close()
@ -120,14 +117,11 @@ func NewCredentialsConfigFromFile(configDir string) (CredentialsConfig, error) {
return authConfig, nil
}
func saveCredentialsConfigFile(authConfig CredentialsConfig, configDir string) error {
path := credentialsConfigFile(configDir)
file, err := os.Create(path)
func saveCredentialsConfigFile(authConfig CredentialsConfig, filePath string) error {
file, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("unable to open %s: %w", path, err)
return fmt.Errorf("unable to create the file at %s: %w", filePath, err)
}
defer file.Close()
encoder := json.NewEncoder(file)
@ -140,6 +134,16 @@ func saveCredentialsConfigFile(authConfig CredentialsConfig, configDir string) e
return nil
}
func credentialsConfigFile(configDir string) string {
return filepath.Join(calculateConfigDir(configDir), credentialsFileName)
func defaultCredentialsConfigFile(configDir string) (string, error) {
dir, err := utilities.CalculateConfigDir(configDir)
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

@ -0,0 +1,107 @@
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

@ -1,41 +0,0 @@
// 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

@ -0,0 +1,40 @@
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,68 +1,56 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor
import (
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client"
"codeflow.dananglin.me.uk/apollo/enbas/internal/config"
internalFlag "codeflow.dananglin.me.uk/apollo/enbas/internal/flag"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model"
)
func getAccountID(gtsClient *client.Client, myAccount bool, accountName, configDir string) (string, error) {
func getAccountID(
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 (
accountID string
err error
account model.Account
err error
)
switch {
case myAccount:
accountID, err = getMyAccountID(gtsClient, configDir)
account, err = getMyAccount(gtsClient)
if err != nil {
return "", fmt.Errorf("unable to get your account ID: %w", err)
return account, fmt.Errorf("unable to get your account ID: %w", err)
}
case accountName != "":
accountID, err = getTheirAccountID(gtsClient, accountName)
case !accountNames.Empty():
account, err = getOtherAccount(gtsClient, accountNames)
if err != nil {
return "", fmt.Errorf("unable to get their account ID: %w", err)
return account, fmt.Errorf("unable to get the account ID: %w", err)
}
default:
return "", NoAccountSpecifiedError{}
return account, NoAccountSpecifiedError{}
}
return accountID, nil
return account, nil
}
func getTheirAccountID(gtsClient *client.Client, accountURI string) (string, error) {
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)
func getMyAccount(gtsClient *client.Client) (model.Account, error) {
account, err := gtsClient.VerifyCredentials()
if err != nil {
return model.Account{}, fmt.Errorf("unable to retrieve your account: %w", err)
}
@ -70,11 +58,35 @@ func getMyAccount(gtsClient *client.Client, configDir string) (model.Account, er
return account, nil
}
func getAccount(gtsClient *client.Client, accountURI string) (model.Account, error) {
account, err := gtsClient.GetAccount(accountURI)
func getOtherAccount(gtsClient *client.Client, accountNames internalFlag.StringSliceValue) (model.Account, error) {
expectedNumAccountNames := 1
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 {
return model.Account{}, fmt.Errorf("unable to retrieve the account details: %w", err)
}
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,49 +1,11 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor
import (
"flag"
"fmt"
"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 {
if a.toResourceType == "" {
return FlagNotSetError{flagText: flagTo}
@ -53,6 +15,7 @@ func (a *AddExecutor) Execute() error {
resourceList: a.addToList,
resourceAccount: a.addToAccount,
resourceBookmarks: a.addToBookmarks,
resourceStatus: a.addToStatus,
}
doFunc, ok := funcMap[a.toResourceType]
@ -60,7 +23,7 @@ func (a *AddExecutor) Execute() error {
return UnsupportedTypeError{resourceType: a.toResourceType}
}
gtsClient, err := client.NewClientFromConfig(a.topLevelFlags.ConfigDir)
gtsClient, err := client.NewClientFromFile(a.config.CredentialsFile)
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err)
}
@ -69,6 +32,13 @@ func (a *AddExecutor) Execute() 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{
resourceAccount: a.addAccountsToList,
}
@ -76,8 +46,8 @@ func (a *AddExecutor) addToList(gtsClient *client.Client) error {
doFunc, ok := funcMap[a.resourceType]
if !ok {
return UnsupportedAddOperationError{
ResourceType: a.resourceType,
AddToResourceType: a.toResourceType,
resourceType: a.resourceType,
addToResourceType: a.toResourceType,
}
}
@ -85,30 +55,35 @@ func (a *AddExecutor) addToList(gtsClient *client.Client) error {
}
func (a *AddExecutor) addAccountsToList(gtsClient *client.Client) error {
if a.listID == "" {
return FlagNotSetError{flagText: flagListID}
}
if len(a.accountNames) == 0 {
if a.accountNames.Empty() {
return NoAccountSpecifiedError{}
}
accountIDs := make([]string, len(a.accountNames))
accounts, err := getOtherAccounts(gtsClient, a.accountNames)
if err != nil {
return fmt.Errorf("unable to get the accounts: %w", err)
}
for ind := range a.accountNames {
accountID, err := getTheirAccountID(gtsClient, a.accountNames[ind])
accountIDs := make([]string, len(accounts))
for ind := range accounts {
relationship, err := gtsClient.GetAccountRelationship(accounts[ind].ID)
if err != nil {
return fmt.Errorf("unable to get the account ID for %s, %w", a.accountNames[ind], err)
return fmt.Errorf("unable to get your relationship to %s: %w", accounts[ind].Acct, err)
}
accountIDs[ind] = accountID
if !relationship.Following {
return NotFollowingError{account: accounts[ind].Acct}
}
accountIDs[ind] = accounts[ind].ID
}
if err := gtsClient.AddAccountsToList(a.listID, accountIDs); err != nil {
return fmt.Errorf("unable to add the accounts to the list: %w", err)
}
fmt.Println("Successfully added the account(s) to the list.")
a.printer.PrintSuccess("Successfully added the account(s) to the list.")
return nil
}
@ -121,8 +96,8 @@ func (a *AddExecutor) addToAccount(gtsClient *client.Client) error {
doFunc, ok := funcMap[a.resourceType]
if !ok {
return UnsupportedAddOperationError{
ResourceType: a.resourceType,
AddToResourceType: a.toResourceType,
resourceType: a.resourceType,
addToResourceType: a.toResourceType,
}
}
@ -130,27 +105,20 @@ func (a *AddExecutor) addToAccount(gtsClient *client.Client) error {
}
func (a *AddExecutor) addNoteToAccount(gtsClient *client.Client) error {
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)
accountID, err := getAccountID(gtsClient, false, a.accountNames)
if err != nil {
return fmt.Errorf("received an error while getting the account ID: %w", err)
}
if a.content == "" {
return EmptyContentError{
ResourceType: resourceNote,
Hint: "please use --" + flagContent,
}
return Error{"please add content to the note you want to add"}
}
if err := gtsClient.SetPrivateNote(accountID, a.content); err != nil {
return fmt.Errorf("unable to add the private note to the account: %w", err)
}
fmt.Println("Successfully added the private note to the account.")
a.printer.PrintSuccess("Successfully added the private note to the account.")
return nil
}
@ -163,8 +131,8 @@ func (a *AddExecutor) addToBookmarks(gtsClient *client.Client) error {
doFunc, ok := funcMap[a.resourceType]
if !ok {
return UnsupportedAddOperationError{
ResourceType: a.resourceType,
AddToResourceType: a.toResourceType,
resourceType: a.resourceType,
addToResourceType: a.toResourceType,
}
}
@ -173,14 +141,105 @@ func (a *AddExecutor) addToBookmarks(gtsClient *client.Client) error {
func (a *AddExecutor) addStatusToBookmarks(gtsClient *client.Client) error {
if a.statusID == "" {
return FlagNotSetError{flagText: flagStatusID}
return MissingIDError{
resource: resourceStatus,
action: "add to your bookmarks",
}
}
if err := gtsClient.AddStatusToBookmarks(a.statusID); err != nil {
return fmt.Errorf("unable to add the status to your bookmarks: %w", err)
}
fmt.Println("Successfully added the status to your bookmarks.")
a.printer.PrintSuccess("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
}

View file

@ -1,41 +1,11 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor
import (
"flag"
"fmt"
"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 {
funcMap := map[string]func(*client.Client) error{
resourceAccount: b.blockAccount,
@ -46,7 +16,7 @@ func (b *BlockExecutor) Execute() error {
return UnsupportedTypeError{resourceType: b.resourceType}
}
gtsClient, err := client.NewClientFromConfig(b.topLevelFlags.ConfigDir)
gtsClient, err := client.NewClientFromFile(b.config.CredentialsFile)
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err)
}
@ -55,30 +25,16 @@ func (b *BlockExecutor) Execute() error {
}
func (b *BlockExecutor) blockAccount(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, false, b.accountName, b.topLevelFlags.ConfigDir)
accountID, err := getAccountID(gtsClient, false, b.accountName)
if err != nil {
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 {
return fmt.Errorf("unable to block the account: %w", err)
}
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.")
b.printer.PrintSuccess("Successfully blocked the account.")
return nil
}

View file

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

View file

@ -1,48 +0,0 @@
// 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"
)

View file

@ -0,0 +1,29 @@
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,90 +1,27 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor
import (
"flag"
"fmt"
"strconv"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model"
"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 {
if c.resourceType == "" {
return FlagNotSetError{flagText: flagType}
}
gtsClient, err := client.NewClientFromConfig(c.topLevelFlags.ConfigDir)
gtsClient, err := client.NewClientFromFile(c.config.CredentialsFile)
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err)
}
funcMap := map[string]func(*client.Client) error{
resourceList: c.createList,
resourceStatus: c.createStatus,
resourceList: c.createList,
resourceStatus: c.createStatus,
resourceMediaAttachment: c.createMediaAttachment,
}
doFunc, ok := funcMap[c.resourceType]
@ -97,12 +34,12 @@ func (c *CreateExecutor) Execute() error {
func (c *CreateExecutor) createList(gtsClient *client.Client) error {
if c.listTitle == "" {
return FlagNotSetError{flagText: flagListTitle}
return Error{"please provide the title of the list that you want to create"}
}
parsedListRepliesPolicy, err := model.ParseListRepliesPolicy(c.listRepliesPolicy)
if err != nil {
return err
return err //nolint:wrapcheck
}
form := client.CreateListForm{
@ -115,8 +52,8 @@ func (c *CreateExecutor) createList(gtsClient *client.Client) error {
return fmt.Errorf("unable to create the list: %w", err)
}
fmt.Println("Successfully created the following list:")
utilities.Display(list, *c.topLevelFlags.NoColor)
c.printer.PrintSuccess("Successfully created the following list:")
c.printer.PrintList(list)
return nil
}
@ -124,30 +61,102 @@ func (c *CreateExecutor) createList(gtsClient *client.Client) error {
func (c *CreateExecutor) createStatus(gtsClient *client.Client) error {
var (
err error
content string
language string
visibility string
sensitive bool
)
switch {
case c.content != "":
content = c.content
case c.fromFile != "":
content, err = utilities.ReadFile(c.fromFile)
if err != nil {
return fmt.Errorf("unable to get the status contents from %q: %w", c.fromFile, err)
attachmentIDs := []string(c.attachmentIDs)
if !c.mediaFiles.Empty() {
descriptionsExists := false
focusValuesExists := false
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,
}
}
}
default:
return EmptyContentError{
ResourceType: resourceStatus,
Hint: "please use --" + flagContent + " or --" + flagFromFile,
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 {
return fmt.Errorf("unable to read the contents from %s: %w", c.mediaDescriptions[ind], err)
}
mediaDescriptions[ind] = mediaDesc
}
}
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()
if err != nil {
fmt.Println("WARNING: Unable to get your posting preferences: %w", err)
c.printer.PrintInfo("WARNING: Unable to get your posting preferences: " + err.Error() + ".\n")
}
if c.language != "" {
@ -162,33 +171,55 @@ func (c *CreateExecutor) createStatus(gtsClient *client.Client) error {
visibility = preferences.PostingDefaultVisibility
}
if c.sensitive != nil {
sensitive = *c.sensitive
if c.sensitive.Value != nil {
sensitive = *c.sensitive.Value
} else {
sensitive = preferences.PostingDefaultSensitive
}
parsedVisibility, err := model.ParseStatusVisibility(visibility)
if err != nil {
return err
return err //nolint:wrapcheck
}
parsedContentType, err := model.ParseStatusContentType(c.contentType)
if err != nil {
return err
return err //nolint:wrapcheck
}
form := client.CreateStatusForm{
Content: content,
ContentType: parsedContentType,
Language: language,
SpoilerText: c.spoilerText,
Boostable: c.boostable,
Federated: c.federated,
Likeable: c.likeable,
Replyable: c.replyable,
Sensitive: sensitive,
Visibility: parsedVisibility,
Content: content,
ContentType: parsedContentType,
Language: language,
SpoilerText: c.summary,
Boostable: c.boostable,
Federated: c.federated,
InReplyTo: c.inReplyTo,
Likeable: c.likeable,
Replyable: c.replyable,
Sensitive: sensitive,
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)
@ -196,8 +227,67 @@ func (c *CreateExecutor) createStatus(gtsClient *client.Client) error {
return fmt.Errorf("unable to create the status: %w", err)
}
fmt.Println("Successfully created the following status:")
utilities.Display(status, *c.topLevelFlags.NoColor)
c.printer.PrintSuccess("Successfully created the status with ID: " + status.ID)
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
}

View file

@ -1,45 +1,21 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor
import (
"flag"
"fmt"
"path/filepath"
"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 {
if d.resourceType == "" {
return FlagNotSetError{flagText: flagType}
}
funcMap := map[string]func(*client.Client) error{
resourceList: d.deleteList,
resourceList: d.deleteList,
resourceStatus: d.deleteStatus,
}
doFunc, ok := funcMap[d.resourceType]
@ -47,7 +23,7 @@ func (d *DeleteExecutor) Execute() error {
return UnsupportedTypeError{resourceType: d.resourceType}
}
gtsClient, err := client.NewClientFromConfig(d.topLevelFlags.ConfigDir)
gtsClient, err := client.NewClientFromFile(d.config.CredentialsFile)
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err)
}
@ -57,14 +33,68 @@ func (d *DeleteExecutor) Execute() error {
func (d *DeleteExecutor) deleteList(gtsClient *client.Client) error {
if d.listID == "" {
return FlagNotSetError{flagText: flagListID}
return MissingIDError{
resource: resourceList,
action: "delete",
}
}
if err := gtsClient.DeleteList(d.listID); err != nil {
return fmt.Errorf("unable to delete the list: %w", err)
}
fmt.Println("The list was successfully deleted.")
d.printer.PrintSuccess("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
}

View file

@ -1,11 +1,6 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor
import (
"flag"
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client"
@ -13,39 +8,14 @@ import (
"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 {
if e.resourceType == "" {
return FlagNotSetError{flagText: flagType}
}
funcMap := map[string]func(*client.Client) error{
resourceList: e.editList,
resourceList: e.editList,
resourceMediaAttachment: e.editMediaAttachment,
}
doFunc, ok := funcMap[e.resourceType]
@ -53,7 +23,7 @@ func (e *EditExecutor) Execute() error {
return UnsupportedTypeError{resourceType: e.resourceType}
}
gtsClient, err := client.NewClientFromConfig(e.topLevelFlags.ConfigDir)
gtsClient, err := client.NewClientFromFile(e.config.CredentialsFile)
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err)
}
@ -63,7 +33,10 @@ func (e *EditExecutor) Execute() error {
func (e *EditExecutor) editList(gtsClient *client.Client) error {
if e.listID == "" {
return FlagNotSetError{flagText: flagListID}
return MissingIDError{
resource: resourceList,
action: "edit",
}
}
list, err := gtsClient.GetList(e.listID)
@ -89,8 +62,68 @@ func (e *EditExecutor) editList(gtsClient *client.Client) error {
return fmt.Errorf("unable to update the list: %w", err)
}
fmt.Println("Successfully updated the list.")
utilities.Display(updatedList, *e.topLevelFlags.NoColor)
e.printer.PrintSuccess("Successfully edited the list.")
e.printer.PrintList(updatedList)
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
}

View file

@ -1,9 +1,15 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor
import "fmt"
type Error struct {
message string
}
func (e Error) Error() string {
return e.message
}
type FlagNotSetError struct {
flagText string
}
@ -27,34 +33,96 @@ func (e NoAccountSpecifiedError) Error() string {
}
type UnsupportedAddOperationError struct {
ResourceType string
AddToResourceType string
resourceType string
addToResourceType string
}
func (e UnsupportedAddOperationError) Error() string {
return "adding '" + e.ResourceType + "' to '" + e.AddToResourceType + "' is not supported"
return "adding '" +
e.resourceType +
"' to '" +
e.addToResourceType +
"' is not supported"
}
type UnsupportedRemoveOperationError struct {
ResourceType string
RemoveFromResourceType string
resourceType string
removeFromResourceType string
}
func (e UnsupportedRemoveOperationError) Error() string {
return "removing '" + e.ResourceType + "' from '" + e.RemoveFromResourceType + "' is not supported"
return "removing '" +
e.resourceType +
"' from '" +
e.removeFromResourceType +
"' is not supported"
}
type EmptyContentError struct {
ResourceType string
Hint string
type UnsupportedShowOperationError struct {
resourceType string
showFromResourceType string
}
func (e EmptyContentError) Error() string {
message := "the content of this " + e.ResourceType + " should not be empty"
if e.Hint != "" {
message += ", " + e.Hint
}
return message
func (e UnsupportedShowOperationError) Error() string {
return "showing '" +
e.resourceType +
"' from '" +
e.showFromResourceType +
"' is not supported"
}
type UnknownCommandError struct {
command string
}
func (e UnknownCommandError) Error() string {
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

@ -1,26 +0,0 @@
// 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,55 +1,22 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor
import (
"flag"
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client"
)
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 {
func (f *FollowExecutor) Execute() error {
funcMap := map[string]func(*client.Client) error{
resourceAccount: c.followAccount,
resourceAccount: f.followAccount,
}
doFunc, ok := funcMap[c.resourceType]
doFunc, ok := funcMap[f.resourceType]
if !ok {
return UnsupportedTypeError{resourceType: c.resourceType}
return UnsupportedTypeError{resourceType: f.resourceType}
}
gtsClient, err := client.NewClientFromConfig(c.topLevelFlags.ConfigDir)
gtsClient, err := client.NewClientFromFile(f.config.CredentialsFile)
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err)
}
@ -57,37 +24,23 @@ func (c *FollowExecutor) Execute() error {
return doFunc(gtsClient)
}
func (c *FollowExecutor) followAccount(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, false, c.accountName, c.topLevelFlags.ConfigDir)
func (f *FollowExecutor) followAccount(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 c.unfollow {
return c.unfollowAccount(gtsClient, accountID)
}
form := client.FollowAccountForm{
AccountID: accountID,
ShowReposts: c.showReposts,
Notify: c.notify,
ShowReposts: f.showReposts,
Notify: f.notify,
}
if err := gtsClient.FollowAccount(form); err != nil {
return fmt.Errorf("unable to follow the account: %w", err)
}
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.")
f.printer.PrintSuccess("Successfully sent the follow request.")
return nil
}

35
internal/executor/init.go Normal file
View file

@ -0,0 +1,35 @@
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,11 +1,6 @@
// 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"
@ -14,35 +9,14 @@ import (
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
)
type LoginExecutor struct {
*flag.FlagSet
topLevelFlags TopLevelFlags
instance string
}
func NewLoginExecutor(tlf TopLevelFlags, name, summary string) *LoginExecutor {
command := LoginExecutor{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
topLevelFlags: tlf,
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 {
func (l *LoginExecutor) Execute() error {
var err error
if c.instance == "" {
return FlagNotSetError{flagText: flagInstance}
if l.instance == "" {
return Error{"please specify the instance that you want to log into"}
}
instance := c.instance
instance := l.instance
if !strings.HasPrefix(instance, "https") || !strings.HasPrefix(instance, "http") {
instance = "https://" + instance
@ -64,23 +38,19 @@ func (c *LoginExecutor) Execute() error {
consentPageURL := gtsClient.AuthCodeURL()
utilities.OpenLink(consentPageURL)
_ = utilities.OpenLink(l.config.Integrations.Browser, consentPageURL)
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:
var builder strings.Builder
%s
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.")
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: ")
Once you have the code please copy and paste it below.
`
fmt.Printf(consentMessageFormat, consentPageURL)
l.printer.PrintInfo(builder.String())
var code string
fmt.Print("Out-of-band token: ")
if _, err := fmt.Scanln(&code); err != nil {
return fmt.Errorf("failed to read access code: %w", err)
@ -95,12 +65,12 @@ Once you have the code please copy and paste it below.
return fmt.Errorf("unable to verify the credentials: %w", err)
}
loginName, err := config.SaveCredentials(c.topLevelFlags.ConfigDir, account.Username, gtsClient.Authentication)
loginName, err := config.SaveCredentials(l.config.CredentialsFile, account.Username, gtsClient.Authentication)
if err != nil {
return fmt.Errorf("unable to save the authentication details: %w", err)
}
fmt.Printf("Successfully logged into %s\n", loginName)
l.printer.PrintSuccess("You have successfully logged as " + loginName + ".")
return nil
}

91
internal/executor/mute.go Normal file
View file

@ -0,0 +1,91 @@
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

@ -0,0 +1,40 @@
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,47 +1,11 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor
import (
"flag"
"fmt"
"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 {
if r.fromResourceType == "" {
return FlagNotSetError{flagText: flagFrom}
@ -51,6 +15,7 @@ func (r *RemoveExecutor) Execute() error {
resourceList: r.removeFromList,
resourceAccount: r.removeFromAccount,
resourceBookmarks: r.removeFromBookmarks,
resourceStatus: r.removeFromStatus,
}
doFunc, ok := funcMap[r.fromResourceType]
@ -58,7 +23,7 @@ func (r *RemoveExecutor) Execute() error {
return UnsupportedTypeError{resourceType: r.fromResourceType}
}
gtsClient, err := client.NewClientFromConfig(r.topLevelFlags.ConfigDir)
gtsClient, err := client.NewClientFromFile(r.config.CredentialsFile)
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err)
}
@ -74,8 +39,8 @@ func (r *RemoveExecutor) removeFromList(gtsClient *client.Client) error {
doFunc, ok := funcMap[r.resourceType]
if !ok {
return UnsupportedRemoveOperationError{
ResourceType: r.resourceType,
RemoveFromResourceType: r.fromResourceType,
resourceType: r.resourceType,
removeFromResourceType: r.fromResourceType,
}
}
@ -84,29 +49,32 @@ func (r *RemoveExecutor) removeFromList(gtsClient *client.Client) error {
func (r *RemoveExecutor) removeAccountsFromList(gtsClient *client.Client) error {
if r.listID == "" {
return FlagNotSetError{flagText: flagListID}
return MissingIDError{
resource: resourceList,
action: "remove from",
}
}
if len(r.accountNames) == 0 {
if r.accountNames.Empty() {
return NoAccountSpecifiedError{}
}
accountIDs := make([]string, len(r.accountNames))
accounts, err := getOtherAccounts(gtsClient, r.accountNames)
if err != nil {
return fmt.Errorf("unable to get the accounts: %w", err)
}
for ind := range r.accountNames {
accountID, err := getTheirAccountID(gtsClient, r.accountNames[ind])
if err != nil {
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 {
return fmt.Errorf("unable to remove the accounts from the list: %w", err)
}
fmt.Println("Successfully removed the account(s) from the list.")
r.printer.PrintSuccess("Successfully removed the account(s) from the list.")
return nil
}
@ -119,8 +87,8 @@ func (r *RemoveExecutor) removeFromAccount(gtsClient *client.Client) error {
doFunc, ok := funcMap[r.resourceType]
if !ok {
return UnsupportedRemoveOperationError{
ResourceType: r.resourceType,
RemoveFromResourceType: r.fromResourceType,
resourceType: r.resourceType,
removeFromResourceType: r.fromResourceType,
}
}
@ -128,11 +96,7 @@ func (r *RemoveExecutor) removeFromAccount(gtsClient *client.Client) error {
}
func (r *RemoveExecutor) removeNoteFromAccount(gtsClient *client.Client) error {
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)
accountID, err := getAccountID(gtsClient, false, r.accountNames)
if err != nil {
return fmt.Errorf("received an error while getting the account ID: %w", err)
}
@ -141,7 +105,7 @@ func (r *RemoveExecutor) removeNoteFromAccount(gtsClient *client.Client) error {
return fmt.Errorf("unable to remove the private note from the account: %w", err)
}
fmt.Println("Successfully removed the private note from the account.")
r.printer.PrintSuccess("Successfully removed the private note from the account.")
return nil
}
@ -154,8 +118,8 @@ func (r *RemoveExecutor) removeFromBookmarks(gtsClient *client.Client) error {
doFunc, ok := funcMap[r.resourceType]
if !ok {
return UnsupportedRemoveOperationError{
ResourceType: r.resourceType,
RemoveFromResourceType: r.fromResourceType,
resourceType: r.resourceType,
removeFromResourceType: r.fromResourceType,
}
}
@ -164,14 +128,62 @@ func (r *RemoveExecutor) removeFromBookmarks(gtsClient *client.Client) error {
func (r *RemoveExecutor) removeStatusFromBookmarks(gtsClient *client.Client) error {
if r.statusID == "" {
return FlagNotSetError{flagText: flagStatusID}
return MissingIDError{
resource: resourceStatus,
action: "remove",
}
}
if err := gtsClient.RemoveStatusFromBookmarks(r.statusID); err != nil {
return fmt.Errorf("unable to remove the status from your bookmarks: %w", err)
}
fmt.Println("Successfully removed the status from your bookmarks.")
r.printer.PrintSuccess("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
}

View file

@ -1,72 +1,35 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor
import (
"flag"
"fmt"
"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/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 {
if s.resourceType == "" {
return FlagNotSetError{flagText: flagType}
}
funcMap := map[string]func(*client.Client) error{
resourceInstance: s.showInstance,
resourceAccount: s.showAccount,
resourceStatus: s.showStatus,
resourceTimeline: s.showTimeline,
resourceList: s.showList,
resourceFollowers: s.showFollowers,
resourceFollowing: s.showFollowing,
resourceBlocked: s.showBlocked,
resourceBookmarks: s.showBookmarks,
resourceInstance: s.showInstance,
resourceAccount: s.showAccount,
resourceStatus: s.showStatus,
resourceTimeline: s.showTimeline,
resourceList: s.showList,
resourceFollowers: s.showFollowers,
resourceFollowing: s.showFollowing,
resourceBlocked: s.showBlocked,
resourceBookmarks: s.showBookmarks,
resourceLiked: s.showLiked,
resourceStarred: s.showLiked,
resourceFollowRequest: s.showFollowRequests,
resourceMutedAccounts: s.showMutedAccounts,
resourceMedia: s.showMedia,
resourceMediaAttachment: s.showMediaAttachment,
}
doFunc, ok := funcMap[s.resourceType]
@ -74,7 +37,7 @@ func (s *ShowExecutor) Execute() error {
return UnsupportedTypeError{resourceType: s.resourceType}
}
gtsClient, err := client.NewClientFromConfig(s.topLevelFlags.ConfigDir)
gtsClient, err := client.NewClientFromFile(s.config.CredentialsFile)
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err)
}
@ -88,65 +51,77 @@ func (s *ShowExecutor) showInstance(gtsClient *client.Client) error {
return fmt.Errorf("unable to retrieve the instance details: %w", err)
}
utilities.Display(instance, *s.topLevelFlags.NoColor)
s.printer.PrintInstance(instance)
return nil
}
func (s *ShowExecutor) showAccount(gtsClient *client.Client) error {
var (
account model.Account
err error
)
if s.myAccount {
account, err = getMyAccount(gtsClient, s.topLevelFlags.ConfigDir)
if err != nil {
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)
}
account, err := getAccount(gtsClient, s.myAccount, s.accountName)
if err != nil {
return fmt.Errorf("unable to get the account information: %w", err)
}
if s.showInBrowser {
utilities.OpenLink(account.URL)
if err := utilities.OpenLink(s.config.Integrations.Browser, account.URL); err != nil {
return fmt.Errorf("unable to open link: %w", err)
}
return nil
}
utilities.Display(account, *s.topLevelFlags.NoColor)
var (
relationship *model.AccountRelationship
preferences *model.Preferences
statuses *model.StatusList
myAccountID string
)
if !s.myAccount && !s.skipAccountRelationship {
relationship, err := gtsClient.GetAccountRelationship(account.ID)
relationship, err = gtsClient.GetAccountRelationship(account.ID)
if err != nil {
return fmt.Errorf("unable to retrieve the relationship to this account: %w", err)
}
utilities.Display(relationship, *s.topLevelFlags.NoColor)
}
if s.myAccount && s.showUserPreferences {
preferences, err := gtsClient.GetUserPreferences()
if err != nil {
return fmt.Errorf("unable to retrieve the user preferences: %w", err)
if s.myAccount {
myAccountID = account.ID
if s.showUserPreferences {
preferences, err = gtsClient.GetUserPreferences()
if err != nil {
return fmt.Errorf("unable to retrieve the user preferences: %w", err)
}
}
}
if s.showStatuses {
form := client.GetAccountStatusesForm{
AccountID: account.ID,
Limit: s.limit,
ExcludeReplies: s.excludeReplies,
ExcludeReblogs: s.excludeBoosts,
Pinned: s.onlyPinned,
OnlyMedia: s.onlyMedia,
OnlyPublic: s.onlyPublic,
}
utilities.Display(preferences, *s.topLevelFlags.NoColor)
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
}
func (s *ShowExecutor) showStatus(gtsClient *client.Client) error {
if s.statusID == "" {
return FlagNotSetError{flagText: flagStatusID}
return MissingIDError{
resource: resourceStatus,
action: "view",
}
}
status, err := gtsClient.GetStatus(s.statusID)
@ -155,12 +130,19 @@ func (s *ShowExecutor) showStatus(gtsClient *client.Client) error {
}
if s.showInBrowser {
utilities.OpenLink(status.URL)
if err := utilities.OpenLink(s.config.Integrations.Browser, status.URL); err != nil {
return fmt.Errorf("unable to open link: %w", err)
}
return nil
}
utilities.Display(status, *s.topLevelFlags.NoColor)
myAccountID, err := getAccountID(gtsClient, true, nil)
if err != nil {
return fmt.Errorf("unable to get your account ID: %w", err)
}
s.printer.PrintStatus(status, myAccountID)
return nil
}
@ -178,7 +160,10 @@ func (s *ShowExecutor) showTimeline(gtsClient *client.Client) error {
timeline, err = gtsClient.GetPublicTimeline(s.limit)
case model.TimelineCategoryList:
if s.listID == "" {
return FlagNotSetError{flagText: flagListID}
return MissingIDError{
resource: resourceList,
action: "view the timeline in",
}
}
var list model.List
@ -191,7 +176,7 @@ func (s *ShowExecutor) showTimeline(gtsClient *client.Client) error {
timeline, err = gtsClient.GetListTimeline(list.ID, list.Title, s.limit)
case model.TimelineCategoryTag:
if s.tag == "" {
return FlagNotSetError{flagText: flagTag}
return Error{"please provide the name of the tag"}
}
timeline, err = gtsClient.GetTagTimeline(s.tag, s.limit)
@ -204,12 +189,17 @@ func (s *ShowExecutor) showTimeline(gtsClient *client.Client) error {
}
if len(timeline.Statuses) == 0 {
fmt.Println("There are no statuses in this timeline.")
s.printer.PrintInfo("There are no statuses in this timeline.\n")
return nil
}
utilities.Display(timeline, *s.topLevelFlags.NoColor)
myAccountID, err := getAccountID(gtsClient, true, nil)
if err != nil {
return fmt.Errorf("unable to get your account ID: %w", err)
}
s.printer.PrintStatusList(timeline, myAccountID)
return nil
}
@ -238,7 +228,7 @@ func (s *ShowExecutor) showList(gtsClient *client.Client) error {
list.Accounts = accountMap
}
utilities.Display(list, *s.topLevelFlags.NoColor)
s.printer.PrintList(list)
return nil
}
@ -250,18 +240,38 @@ func (s *ShowExecutor) showLists(gtsClient *client.Client) error {
}
if len(lists) == 0 {
fmt.Println("You have no lists.")
s.printer.PrintInfo("You have no lists.\n")
return nil
}
utilities.Display(lists, *s.topLevelFlags.NoColor)
s.printer.PrintLists(lists)
return nil
}
func (s *ShowExecutor) showFollowers(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, s.myAccount, s.accountName, s.topLevelFlags.ConfigDir)
if s.fromResourceType == "" {
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 {
return fmt.Errorf("received an error while getting the account ID: %w", err)
}
@ -272,16 +282,36 @@ func (s *ShowExecutor) showFollowers(gtsClient *client.Client) error {
}
if len(followers.Accounts) > 0 {
utilities.Display(followers, *s.topLevelFlags.NoColor)
s.printer.PrintAccountList(followers)
} else {
fmt.Println("There are no followers for this account or the list is hidden.")
s.printer.PrintInfo("There are no followers for this account (or the list is hidden).\n")
}
return nil
}
func (s *ShowExecutor) showFollowing(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, s.myAccount, s.accountName, s.topLevelFlags.ConfigDir)
if s.fromResourceType == "" {
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 {
return fmt.Errorf("received an error while getting the account ID: %w", err)
}
@ -292,9 +322,9 @@ func (s *ShowExecutor) showFollowing(gtsClient *client.Client) error {
}
if len(following.Accounts) > 0 {
utilities.Display(following, *s.topLevelFlags.NoColor)
s.printer.PrintAccountList(following)
} else {
fmt.Println("This account is not following anyone or the list is hidden.")
s.printer.PrintInfo("This account is not following anyone or the list is hidden.\n")
}
return nil
@ -307,9 +337,9 @@ func (s *ShowExecutor) showBlocked(gtsClient *client.Client) error {
}
if len(blocked.Accounts) > 0 {
utilities.Display(blocked, *s.topLevelFlags.NoColor)
s.printer.PrintAccountList(blocked)
} else {
fmt.Println("You have no blocked accounts.")
s.printer.PrintInfo("You have no blocked accounts.\n")
}
return nil
@ -322,9 +352,153 @@ func (s *ShowExecutor) showBookmarks(gtsClient *client.Client) error {
}
if len(bookmarks.Statuses) > 0 {
utilities.Display(bookmarks, *s.topLevelFlags.NoColor)
myAccountID, err := getAccountID(gtsClient, true, nil)
if err != nil {
return fmt.Errorf("unable to get your account ID: %w", err)
}
s.printer.PrintStatusList(bookmarks, myAccountID)
} else {
fmt.Println("You have no bookmarks.")
s.printer.PrintInfo("You have no bookmarks.\n")
}
return nil
}
func (s *ShowExecutor) showLiked(gtsClient *client.Client) error {
liked, err := gtsClient.GetLikedStatuses(s.limit, s.resourceType)
if err != nil {
return fmt.Errorf("unable to retrieve the list of your %s statuses: %w", s.resourceType, err)
}
if len(liked.Statuses) > 0 {
myAccountID, err := getAccountID(gtsClient, true, nil)
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

View file

@ -1,61 +1,38 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor
import (
"flag"
"fmt"
"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 {
funcMap := map[string]func() error{
resourceAccount: s.switchToAccount,
}
doFunc, ok := funcMap[s.toResourceType]
doFunc, ok := funcMap[s.to]
if !ok {
return UnsupportedTypeError{resourceType: s.toResourceType}
return UnsupportedTypeError{resourceType: s.to}
}
return doFunc()
}
func (s *SwitchExecutor) switchToAccount() error {
if s.accountName == "" {
return NoAccountSpecifiedError{}
expectedNumAccountNames := 1
if !s.accountName.ExpectedLength(expectedNumAccountNames) {
return fmt.Errorf(
"found an unexpected number of --account-name flags: expected %d",
expectedNumAccountNames,
)
}
if err := config.UpdateCurrentAccount(s.accountName, s.topLevelFlags.ConfigDir); err != nil {
if err := config.UpdateCurrentAccount(s.accountName[0], s.config.CredentialsFile); err != nil {
return fmt.Errorf("unable to switch account to the account: %w", err)
}
fmt.Printf("The current account is now set to %q.\n", s.accountName)
s.printer.PrintSuccess("The current account is now set to '" + s.accountName[0] + "'.")
return nil
}

View file

@ -0,0 +1,40 @@
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

@ -0,0 +1,40 @@
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

@ -0,0 +1,86 @@
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

@ -1,41 +0,0 @@
// 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,59 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor
import (
"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())
func (v *VersionExecutor) Execute() error {
v.printer.PrintVersion(v.full)
return nil
}

View file

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

View file

@ -0,0 +1,38 @@
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

@ -0,0 +1,100 @@
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)
}
}

49
internal/flag/intslice.go Normal file
View file

@ -0,0 +1,49 @@
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

@ -0,0 +1,56 @@
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

@ -0,0 +1,31 @@
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

@ -0,0 +1,57 @@
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

@ -0,0 +1,79 @@
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

@ -0,0 +1,67 @@
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,
)
}
}
}

13
internal/info/info.go Normal file
View file

@ -0,0 +1,13 @@
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
)

View file

@ -1,12 +0,0 @@
// 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"
)

170
internal/media/media.go Normal file
View file

@ -0,0 +1,170 @@
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,14 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model
import (
"fmt"
"time"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
)
type Account struct {
@ -63,60 +56,6 @@ type Field struct {
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 {
ID string `json:"id"`
PrivateNote string `json:"note"`
@ -134,97 +73,17 @@ type AccountRelationship struct {
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
const (
AccountListFollowers AccountListType = iota
AccountListFollowing
AccountListBlockedAccount
AccountListFollowRequests
AccountListMuted
)
type AccountList struct {
Type AccountListType
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,7 +1,3 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model
type Application struct {
@ -9,7 +5,7 @@ type Application struct {
ClientSecret string `json:"client_secret"`
ID string `json:"id"`
Name string `json:"name"`
RedirectUri string `json:"redirect_uri"`
RedirectURI string `json:"redirect_uri"`
VapidKey string `json:"vapid_key"`
Website string `json:"website"`
}

View file

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

View file

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

View file

@ -1,15 +1,5 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model
import (
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
)
type InstanceV2 struct {
AccountDomain string `json:"account_domain"`
Configuration InstanceConfiguration `json:"configuration"`
@ -101,7 +91,7 @@ type InstanceV2Thumbnail struct {
ThumbnailDescription string `json:"thumbnail_description"`
ThumbnailType string `json:"thumbnail_type"`
URL string `json:"url"`
Versions InstanceV2ThumbnailVersions `json:"versions"`
Versions InstanceV2ThumbnailVersions `json:"versions"`
}
type InstanceV2ThumbnailVersions struct {
@ -116,48 +106,3 @@ type InstanceV2Usage struct {
type InstanceV2Users struct {
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,14 +1,8 @@
// 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"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
)
type ListRepliesPolicy int
@ -107,57 +101,3 @@ type List struct {
Title string `json:"title"`
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
}

23
internal/model/poll.go Normal file
View file

@ -0,0 +1,23 @@
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,15 +1,5 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model
import (
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
)
type Preferences struct {
PostingDefaultVisibility string `json:"posting:default:visibility"`
PostingDefaultSensitive bool `json:"posting:default:sensitive"`
@ -18,19 +8,3 @@ type Preferences struct {
ReadingExpandSpoilers bool `json:"reading:expand:spoilers"`
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,15 +1,7 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model
import (
"fmt"
"strings"
"time"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
)
type Status struct {
@ -30,13 +22,13 @@ type Status struct {
Mentions []Mention `json:"mentions"`
Muted bool `json:"muted"`
Pinned bool `json:"pinned"`
Poll Poll `json:"poll"`
Poll *Poll `json:"poll"`
Reblog *StatusReblogged `json:"reblog"`
Reblogged bool `json:"reblogged"`
ReblogsCount int `json:"reblogs_count"`
RepliesCount int `json:"replies_count"`
Sensitive bool `json:"sensitive"`
SpolierText string `json:"spoiler_text"`
SpoilerText string `json:"spoiler_text"`
Tags []Tag `json:"tags"`
Text string `json:"text"`
URI string `json:"uri"`
@ -68,24 +60,6 @@ type Mention struct {
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 string `json:"votes_count"`
}
type StatusReblogged struct {
Account Account `json:"account"`
Application Application `json:"application"`
@ -104,12 +78,12 @@ type StatusReblogged struct {
Mentions []Mention `json:"mentions"`
Muted bool `json:"muted"`
Pinned bool `json:"pinned"`
Poll Poll `json:"poll"`
Poll *Poll `json:"poll"`
Reblogged bool `json:"reblogged"`
RebloggsCount int `json:"reblogs_count"`
RepliesCount int `json:"replies_count"`
Sensitive bool `json:"sensitive"`
SpolierText string `json:"spoiler_text"`
SpoilerText string `json:"spoiler_text"`
Tags []Tag `json:"tags"`
Text string `json:"text"`
URI string `json:"uri"`
@ -157,93 +131,7 @@ type MediaDimensions struct {
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 StatusListType
Name string
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,7 +1,3 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package model
import (

View file

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

View file

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

144
internal/printer/account.go Normal file
View file

@ -0,0 +1,144 @@
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,8 +1,4 @@
// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package utilities
package printer
import (
"io"
@ -23,7 +19,7 @@ type htmlConvertState struct {
orderedListIndex int
}
func ConvertHTMLToText(text string) string {
func (p Printer) convertHTMLToText(text string, wrapLines bool) string {
var builder strings.Builder
state := htmlConvertState{
@ -37,6 +33,10 @@ func ConvertHTMLToText(text string) string {
tt := token.Next()
switch tt {
case html.ErrorToken:
if wrapLines {
return p.wrapLines(builder.String(), 0)
}
return builder.String()
case html.TextToken:
text := token.Token().Data
@ -66,7 +66,7 @@ func processTagToken(state *htmlConvertState, writer io.StringWriter, tag string
case "<li>":
switch state.htmlListType {
case htmlUnorderedList:
_, _ = writer.WriteString("• ")
_, _ = writer.WriteString(symbolBullet + " ")
case htmlOrderedList:
_, _ = writer.WriteString(strconv.Itoa(state.orderedListIndex) + ". ")
state.orderedListIndex++

View file

@ -0,0 +1,38 @@
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())
}

45
internal/printer/list.go Normal file
View file

@ -0,0 +1,45 @@
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

@ -0,0 +1,45 @@
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())
}

196
internal/printer/printer.go Normal file
View file

@ -0,0 +1,196 @@
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)
}

277
internal/printer/status.go Normal file
View file

@ -0,0 +1,277 @@
package printer
import (
"math"
"strconv"
"strings"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model"
)
func (p Printer) PrintStatus(status model.Status, userAccountID string) {
var builder strings.Builder
// The account information
builder.WriteString("\n" + p.fullDisplayNameFormat(status.Account.DisplayName, status.Account.Acct))
// The ID of the status
builder.WriteString("\n\n" + p.headerFormat("STATUS ID:"))
builder.WriteString("\n" + status.ID)
// The subject, summary of content warning of the status
if status.SpoilerText != "" {
builder.WriteString("\n\n" + p.headerFormat("SUMMARY:"))
builder.WriteString("\n" + status.SpoilerText)
}
// The content of the status.
builder.WriteString("\n\n" + p.headerFormat("CONTENT:"))
builder.WriteString(p.convertHTMLToText(status.Content, true))
// Details of media attachments (if any).
if len(status.MediaAttachments) > 0 {
builder.WriteString("\n\n" + p.headerFormat("MEDIA ATTACHMENTS:"))
for ind, media := range status.MediaAttachments {
builder.WriteString("\n\n[" + strconv.Itoa(ind) + "] " + p.fieldFormat("ID:") + " " + media.ID)
builder.WriteString("\n " + p.fieldFormat("Type:") + " " + media.Type)
description := media.Description
if description == "" {
description = noMediaDescription
}
builder.WriteString("\n " + p.fieldFormat("Description:") + " " + description)
builder.WriteString("\n " + p.fieldFormat("Media URL:") + " " + media.URL)
}
}
// If a poll exists in a status, write the contents to the builder.
if status.Poll != nil {
pollOwner := false
if status.Account.ID == userAccountID {
pollOwner = true
}
builder.WriteString("\n\n" + p.headerFormat("POLL DETAILS:"))
builder.WriteString(p.pollDetails(*status.Poll, pollOwner))
}
// Status creation time
builder.WriteString("\n\n" + p.headerFormat("CREATED AT:"))
builder.WriteString("\n" + p.formatDateTime(status.CreatedAt))
// Status stats
builder.WriteString("\n\n" + p.headerFormat("STATS:"))
builder.WriteString("\n" + p.fieldFormat("Boosts: ") + strconv.Itoa(status.ReblogsCount))
builder.WriteString("\n" + p.fieldFormat("Likes: ") + strconv.Itoa(status.FavouritesCount))
builder.WriteString("\n" + p.fieldFormat("Replies: ") + strconv.Itoa(status.RepliesCount))
// The user's actions on the status
builder.WriteString("\n\n" + p.headerFormat("YOUR ACTIONS:"))
builder.WriteString("\n" + p.fieldFormat("Boosted: ") + strconv.FormatBool(status.Reblogged))
builder.WriteString("\n" + p.fieldFormat("Liked: ") + strconv.FormatBool(status.Favourited))
builder.WriteString("\n" + p.fieldFormat("Bookmarked: ") + strconv.FormatBool(status.Bookmarked))
builder.WriteString("\n" + p.fieldFormat("Muted: ") + strconv.FormatBool(status.Muted))
// Status visibility
builder.WriteString("\n\n" + p.headerFormat("VISIBILITY:"))
builder.WriteString("\n" + status.Visibility.String())
// Status URL
builder.WriteString("\n\n" + p.headerFormat("URL:"))
builder.WriteString("\n" + status.URL)
builder.WriteString("\n\n")
p.print(builder.String())
}
func (p Printer) PrintStatusList(list model.StatusList, userAccountID string) {
p.print(p.statusList(list, userAccountID))
}
func (p Printer) statusList(list model.StatusList, userAccountID string) string {
var builder strings.Builder
builder.WriteString(p.headerFormat(list.Name) + "\n")
for _, status := range list.Statuses {
statusID := status.ID
statusOwnerID := status.Account.ID
createdAt := p.formatDateTime(status.CreatedAt)
boostedAt := ""
content := status.Content
poll := status.Poll
mediaAttachments := status.MediaAttachments
summary := status.SpoilerText
switch {
case status.Reblog != nil:
builder.WriteString("\n" + p.wrapLines(
p.fullDisplayNameFormat(status.Account.DisplayName, status.Account.Acct)+
" boosted this status from "+
p.fullDisplayNameFormat(status.Reblog.Account.DisplayName, status.Reblog.Account.Acct)+
":",
0,
))
statusID = status.Reblog.ID
statusOwnerID = status.Reblog.Account.ID
createdAt = p.formatDateTime(status.Reblog.CreatedAt)
boostedAt = p.formatDateTime(status.CreatedAt)
content = status.Reblog.Content
poll = status.Reblog.Poll
mediaAttachments = status.Reblog.MediaAttachments
summary = status.Reblog.SpoilerText
case status.InReplyToID != "":
builder.WriteString("\n" + p.wrapLines(
p.fullDisplayNameFormat(status.Account.DisplayName, status.Account.Acct)+
" posted in reply to "+
status.InReplyToID+
":",
0,
))
default:
builder.WriteString("\n" + p.fullDisplayNameFormat(status.Account.DisplayName, status.Account.Acct) + " posted:")
}
if summary != "" {
builder.WriteString("\n\n" + p.bold(p.wrapLines(summary, 0)))
}
builder.WriteString("\n" + p.convertHTMLToText(content, true))
if poll != nil {
pollOwner := false
if statusOwnerID == userAccountID {
pollOwner = true
}
builder.WriteString(p.pollDetails(*poll, pollOwner))
}
for _, media := range mediaAttachments {
builder.WriteString("\n\n" + symbolImage + " " + p.fieldFormat("Media attachment: ") + media.ID)
builder.WriteString("\n " + p.fieldFormat("Media type: ") + media.Type + "\n")
description := " " + p.fieldFormat("Description: ")
if media.Description == "" {
description += noMediaDescription
} else {
description += media.Description
}
builder.WriteString(p.wrapLines(description, 2))
}
boosted := symbolBoosted
if status.Reblogged {
boosted = p.theme.boldyellow + symbolBoosted + p.theme.reset
}
liked := symbolNotLiked
if status.Favourited {
liked = p.theme.boldyellow + symbolLiked + p.theme.reset
}
bookmarked := symbolNotBookmarked
if status.Bookmarked {
bookmarked = p.theme.boldyellow + symbolBookmarked + p.theme.reset
}
builder.WriteString("\n\n" + boosted + " " + p.fieldFormat("boosted: ") + strconv.FormatBool(status.Reblogged))
builder.WriteString("\n" + liked + " " + p.fieldFormat("liked: ") + strconv.FormatBool(status.Favourited))
builder.WriteString("\n" + bookmarked + " " + p.fieldFormat("bookmarked: ") + strconv.FormatBool(status.Bookmarked))
builder.WriteString(
"\n\n" +
p.fieldFormat("Status ID: ") + statusID +
"\n" + p.fieldFormat("Created at: ") + createdAt,
)
if boostedAt != "" {
builder.WriteString("\n" + p.fieldFormat("Boosted at: ") + boostedAt)
}
builder.WriteString("\n" + p.statusSeparator + "\n")
}
return builder.String()
}
func (p Printer) pollDetails(poll model.Poll, owner bool) string {
var builder strings.Builder
for ind, option := range poll.Options {
var (
votage float64
percentage int
)
// Show the poll results under any of the following conditions:
// - the user is the owner of the poll
// - the poll has expired
// - the user has voted in the poll
if owner || poll.Expired || poll.Voted {
if poll.VotesCount == 0 {
percentage = 0
} else {
votage = float64(option.VotesCount) / float64(poll.VotesCount)
percentage = int(math.Floor(100 * votage))
}
optionTitle := "\n\n" + "[" + strconv.Itoa(ind) + "] " + option.Title
for _, vote := range poll.OwnVotes {
if ind == vote {
optionTitle += " " + symbolCheckMark
break
}
}
builder.WriteString(optionTitle)
builder.WriteString(p.pollMeter(votage))
builder.WriteString("\n" + strconv.Itoa(option.VotesCount) + " votes " + "(" + strconv.Itoa(percentage) + "%)")
} else {
builder.WriteString("\n" + "[" + strconv.Itoa(ind) + "] " + option.Title)
}
}
pollStatusField := "Poll is open until: "
if poll.Expired {
pollStatusField = "Poll was closed on: "
}
builder.WriteString("\n\n" + p.fieldFormat(pollStatusField) + p.formatDateTime(poll.ExpiredAt))
builder.WriteString("\n" + p.fieldFormat("Total votes: ") + strconv.Itoa(poll.VotesCount))
builder.WriteString("\n" + p.fieldFormat("Multiple choices allowed: ") + strconv.FormatBool(poll.Multiple))
return builder.String()
}
func (p Printer) pollMeter(votage float64) string {
numVoteBlocks := int(math.Floor(float64(p.lineWrapCharacterLimit) * votage))
numBackgroundBlocks := p.lineWrapCharacterLimit - numVoteBlocks
voteBlockColour := p.theme.boldgreen
backgroundBlockColor := p.theme.grey
if p.noColor {
voteBlockColour = p.theme.reset
if numVoteBlocks == 0 {
numVoteBlocks = 1
}
}
meter := "\n" + voteBlockColour + strings.Repeat(symbolPollMeter, numVoteBlocks) + p.theme.reset
if !p.noColor {
meter += backgroundBlockColor + strings.Repeat(symbolPollMeter, numBackgroundBlocks) + p.theme.reset
}
return meter
}

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