mirror of
https://github.com/chenasraf/sofmani.git
synced 2026-05-17 17:28:04 +00:00
feat: add category display options
This commit is contained in:
@@ -150,10 +150,11 @@ For a full breakdown with all the supported options, see
|
||||
### Global Options
|
||||
|
||||
| Field | Type | Description |
|
||||
| --------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `debug` | Boolean | Enable or disable debug mode. Default: `false`. |
|
||||
| `check_updates` | Boolean | Enable or disable checking for updates before running operations. Default: `false`. |
|
||||
| `summary` | Boolean | Enable or disable the installation summary at the end. Default: `true`. |
|
||||
| `category_display` | String | Controls how category headers are rendered. Values: `border` (default), `border-compact`, `minimal`. |
|
||||
| `defaults` | Object | Defaults to apply to all installer types, such as specifying supported platforms or commonly used flags. |
|
||||
| `env` | Object | Environment variables that will be set for the context of the installer. OS env vars are passed, and may be overridden for this config and all of its installers here. |
|
||||
| `install` | Array | Installation steps to execute. |
|
||||
|
||||
@@ -12,6 +12,18 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// CategoryDisplayMode controls how category headers are rendered.
|
||||
type CategoryDisplayMode string
|
||||
|
||||
const (
|
||||
// CategoryDisplayBorder renders categories with a full border and spacing.
|
||||
CategoryDisplayBorder CategoryDisplayMode = "border"
|
||||
// CategoryDisplayBorderCompact renders categories with a border but no spacing.
|
||||
CategoryDisplayBorderCompact CategoryDisplayMode = "border-compact"
|
||||
// CategoryDisplayMinimal renders categories without a border or spacing.
|
||||
CategoryDisplayMinimal CategoryDisplayMode = "minimal"
|
||||
)
|
||||
|
||||
// AppConfig represents the main application configuration.
|
||||
type AppConfig struct {
|
||||
// Debug enables or disables debug mode.
|
||||
@@ -20,6 +32,8 @@ type AppConfig struct {
|
||||
CheckUpdates *bool `json:"check_updates" yaml:"check_updates"`
|
||||
// Summary enables or disables the installation summary at the end.
|
||||
Summary *bool `json:"summary" yaml:"summary"`
|
||||
// CategoryDisplay controls how category headers are rendered.
|
||||
CategoryDisplay *CategoryDisplayMode `json:"category_display" yaml:"category_display"`
|
||||
// Install is a list of installers to run.
|
||||
Install []InstallerData `json:"install" yaml:"install"`
|
||||
// Defaults provides default configurations for installer types.
|
||||
@@ -60,6 +74,14 @@ type AppConfigDefaults struct {
|
||||
Type *map[InstallerType]InstallerData `json:"type" yaml:"type"`
|
||||
}
|
||||
|
||||
// GetCategoryDisplay returns the effective category display mode, defaulting to "border".
|
||||
func (c *AppConfig) GetCategoryDisplay() CategoryDisplayMode {
|
||||
if c.CategoryDisplay != nil {
|
||||
return *c.CategoryDisplay
|
||||
}
|
||||
return CategoryDisplayBorder
|
||||
}
|
||||
|
||||
// Environ returns the combined environment variables as a slice of strings.
|
||||
func (c *AppConfig) Environ() []string {
|
||||
return utils.EnvMapAsSlice(utils.CombineEnvMaps(c.Env, c.PlatformEnv.Resolve()))
|
||||
|
||||
@@ -5,37 +5,39 @@ Here is a breakdown of all configuration options:
|
||||
## Global Options
|
||||
|
||||
- **`install`** (Array)
|
||||
|
||||
- Installation steps to execute.
|
||||
|
||||
- See [Installer Configuration](./installer-configuration.md) for supported types and options that
|
||||
you can provide.
|
||||
|
||||
- **`debug`** (Boolean)
|
||||
|
||||
- Enable or disable debug mode.
|
||||
- Default: `false`.
|
||||
|
||||
- **`check_updates`** (Boolean)
|
||||
|
||||
- Enable or disable checking for updates before running operations.
|
||||
- Default: `false`.
|
||||
|
||||
- **`summary`** (Boolean)
|
||||
|
||||
- Enable or disable the installation summary at the end.
|
||||
- The summary shows newly installed and upgraded software in a hierarchical format.
|
||||
- Default: `true`.
|
||||
|
||||
- **`defaults`** (Object)
|
||||
- **`category_display`** (String)
|
||||
- Controls how category headers are rendered in the output.
|
||||
- Values:
|
||||
- `border` — Full border with spacing before and after (default).
|
||||
- `border-compact` — Border without spacing before and after.
|
||||
- `minimal` — Plain text without border or spacing.
|
||||
- Default: `border`.
|
||||
|
||||
- **`defaults`** (Object)
|
||||
- Defaults to apply to all installer types, such as specifying supported platforms or commonly
|
||||
used flags.
|
||||
|
||||
- **`defaults.type`**
|
||||
|
||||
A mapping between each type (key) and their default options (value).
|
||||
|
||||
- See [Installer Configuration](./installer-configuration.md) for supported types and options
|
||||
that you can override.
|
||||
|
||||
@@ -47,8 +49,8 @@ Here is a breakdown of all configuration options:
|
||||
- **`machine_aliases`** (Object)
|
||||
- A mapping of friendly names to machine IDs.
|
||||
- Use `sofmani --machine-id` to get the machine ID for each of your machines.
|
||||
- These aliases can then be used in installer `machines.only` and `machines.except` fields
|
||||
instead of the raw machine IDs.
|
||||
- These aliases can then be used in installer `machines.only` and `machines.except` fields instead
|
||||
of the raw machine IDs.
|
||||
- Example:
|
||||
```yaml
|
||||
machine_aliases:
|
||||
@@ -63,6 +65,7 @@ Here is a breakdown of all configuration options:
|
||||
debug: false
|
||||
check_updates: true
|
||||
summary: true
|
||||
category_display: border
|
||||
defaults:
|
||||
type:
|
||||
brew:
|
||||
|
||||
@@ -11,16 +11,14 @@ entries that display a bordered header in the output but don't perform any insta
|
||||
### Fields
|
||||
|
||||
- **`category`**
|
||||
|
||||
- **Type**: String (required for category entries)
|
||||
- **Description**: The category name to display. When this field is present, the entry is treated
|
||||
as a category header, not an installer.
|
||||
|
||||
- **`desc`**
|
||||
|
||||
- **Type**: String (optional)
|
||||
- **Description**: An optional description shown below the category name. Supports multi-line
|
||||
text with automatic word wrapping. Existing line breaks are preserved.
|
||||
- **Description**: An optional description shown below the category name. Supports multi-line text
|
||||
with automatic word wrapping. Existing line breaks are preserved.
|
||||
|
||||
### Example
|
||||
|
||||
@@ -54,7 +52,11 @@ install:
|
||||
|
||||
### Output
|
||||
|
||||
Categories are displayed with a bordered header:
|
||||
The appearance of category headers is controlled by the top-level `category_display` option.
|
||||
|
||||
#### `border` (default)
|
||||
|
||||
Categories are displayed with a bordered header and spacing before/after:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
@@ -72,7 +74,27 @@ With a description:
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The box width adapts to narrower terminals (minimum of terminal width or 60 characters).
|
||||
#### `border-compact`
|
||||
|
||||
Same as `border`, but without the empty lines before and after the box.
|
||||
|
||||
#### `minimal`
|
||||
|
||||
Categories are displayed as plain text without any border or spacing:
|
||||
|
||||
```
|
||||
Development Tools
|
||||
```
|
||||
|
||||
With a description:
|
||||
|
||||
```
|
||||
System Utilities
|
||||
Tools for system maintenance and monitoring.
|
||||
```
|
||||
|
||||
The box width (for `border` and `border-compact`) adapts to narrower terminals (minimum of terminal
|
||||
width or 60 characters).
|
||||
|
||||
---
|
||||
|
||||
@@ -82,33 +104,28 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
`type`.
|
||||
|
||||
- **`name`**
|
||||
|
||||
- **Type**: String (required)
|
||||
- **Description**: Identifier for the step. It does not have to be unique, but is usually used to
|
||||
check for the app's existence (can be overridden using `bin_name`).
|
||||
|
||||
- **`type`**
|
||||
|
||||
- **Type**: String (required)
|
||||
- **Description**: Type of the step. See [supported types](#supported-type-of-installers) for a
|
||||
comprehensive list of supported values.
|
||||
|
||||
- **`enabled`**
|
||||
|
||||
- **Type**: String or Boolean (optional)
|
||||
- **Description**: Enable or disable the step. Disabled steps are not run. This can either be a
|
||||
static boolean (`true` or `false`), or a command that returns a success status code for true, or
|
||||
a failure for false.
|
||||
|
||||
- **`tags`**
|
||||
|
||||
- **Type** String (optional)
|
||||
- **Description**: Arbitrary tags to attach to an installer. These can later be used to filter
|
||||
this installer in or out when running sofmani. This should be a string containing
|
||||
space-separated tags.
|
||||
|
||||
- **`platforms`**
|
||||
|
||||
- **Type**: Object (optional)
|
||||
- **Description**: Platform-specific execution controls. See `platforms` subfields below.
|
||||
- **Subfields**:
|
||||
@@ -121,12 +138,11 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
- **Description**: Platforms where the step should **not** execute; replaces `platforms.only`.
|
||||
|
||||
- **`machines`**
|
||||
|
||||
- **Type**: Object (optional)
|
||||
- **Description**: Machine-specific execution controls. Use this to run installers only on
|
||||
specific machines. Get the machine ID by running `sofmani --machine-id`. You can use either
|
||||
raw machine IDs or aliases defined in the top-level `machine_aliases` configuration.
|
||||
See `machines` subfields below.
|
||||
specific machines. Get the machine ID by running `sofmani --machine-id`. You can use either raw
|
||||
machine IDs or aliases defined in the top-level `machine_aliases` configuration. See `machines`
|
||||
subfields below.
|
||||
- **Subfields**:
|
||||
- **`machines.only`**
|
||||
- **Type**: Array of Strings
|
||||
@@ -137,26 +153,22 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
- **Description**: Machine IDs or aliases where the step should **not** execute.
|
||||
|
||||
- **`steps`**
|
||||
|
||||
- **Type**: Array of Installers
|
||||
- **Description**: Sub-steps for `group` type. Allows nesting multiple steps together. Ignored for
|
||||
all other types.
|
||||
|
||||
- **`opts`**
|
||||
|
||||
- **Type**: Object (optional)
|
||||
- **Description**: Step-specific options and configurations. Content varies depending on the
|
||||
`type`. See [supported types](#supported-type-of-installers) for a comprehensive list of
|
||||
supported values.
|
||||
|
||||
- **`bin_name`**
|
||||
|
||||
- **Type**: String (optional)
|
||||
- **Description**: Binary name for the installed software, used instead of `name` when checking
|
||||
for app's existence.
|
||||
|
||||
- **`check_has_update`**
|
||||
|
||||
- **Type**: String (shell script)
|
||||
- **Description**: Shell command to check whether an update is available for the installed
|
||||
software. This will override the default check provided by the corresponding `type`. The check
|
||||
@@ -164,29 +176,24 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
the app is up to date.
|
||||
|
||||
- **`check_installed`**
|
||||
|
||||
- **Type**: String (shell script)
|
||||
- **Description**: Shell command to check if the step has already been installed. If the check
|
||||
succeeds (exits with status 0), it means the app is already installed and can be skipped if not
|
||||
checking for updates.
|
||||
|
||||
- **`pre_install`**
|
||||
|
||||
- **Type**: String (shell script)
|
||||
- **Description**: Shell script to execute _before_ the step is installed.
|
||||
|
||||
- **`post_install`**
|
||||
|
||||
- **Type**: String (shell script)
|
||||
- **Description**: Shell script to execute _after_ the step is installed.
|
||||
|
||||
- **`pre_update`**
|
||||
|
||||
- **Type**: String (shell script)
|
||||
- **Description**: Shell script to execute _before_ the step is updated (if applicable).
|
||||
|
||||
- **`post_update`**
|
||||
|
||||
- **Type**: String (shell script)
|
||||
- **Description**: Shell script to execute _after_ the step is updated (if applicable).
|
||||
|
||||
@@ -205,7 +212,6 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
shell will be used.
|
||||
|
||||
- **`skip_summary`**
|
||||
|
||||
- **Type**: Boolean or Object (optional)
|
||||
- **Description**: Exclude this installer from the installation summary. Useful for installers
|
||||
that always run (like config sync scripts) and would clutter the summary output.
|
||||
@@ -216,6 +222,7 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
- **`skip_summary.install`**: Boolean - exclude from the "Installed" section of the summary.
|
||||
- **`skip_summary.update`**: Boolean - exclude from the "Upgraded" section of the summary.
|
||||
- **Examples**:
|
||||
|
||||
```yaml
|
||||
# Skip from both install and update summaries
|
||||
- name: sync-dotfiles
|
||||
@@ -243,21 +250,18 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
## Supported `type` of Installers
|
||||
|
||||
- **`shell`**
|
||||
|
||||
- **Description**: Executes arbitrary shell commands.
|
||||
- **Options**:
|
||||
- `opts.command`: The command to execute for installing.
|
||||
- `opts.update_command`: The command to execute for updating.
|
||||
|
||||
- **`group`**
|
||||
|
||||
- **Description**: Executes a logical group of steps in sequence.
|
||||
- Allows nesting multiple steps together.
|
||||
- **Options**:
|
||||
- `steps`: List of nested steps.
|
||||
|
||||
- **`git`**
|
||||
|
||||
- **Description**: Clones a git repository to a local directory.
|
||||
- If `name` is a full git URL (https or SSH), the repository is cloned directly.
|
||||
- If it is a repository path, e.g. `chenasraf/sofmani`, GitHub is assumed.
|
||||
@@ -269,10 +273,8 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
- `opts.update_flags`: Additional flags to pass only to `git pull`.
|
||||
|
||||
- **`github-release`**
|
||||
|
||||
- **Description**: Downloads a GitHub release asset. Optionally untar/unzip the downloaded file.
|
||||
- **Options**:
|
||||
|
||||
- `opts.repository`: The repository to download from. Should be in the format:
|
||||
`user/repository-name`
|
||||
- `opts.destination`: The target directory to extract the files to.
|
||||
@@ -285,7 +287,6 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
This should either be a string, or a map of platforms to filenames.
|
||||
|
||||
You can use Go template syntax to insert dynamic values into the filename:
|
||||
|
||||
- `{{ .Tag }}` - the full tag name, e.g. `v1.0.0`
|
||||
- `{{ .Version }}` - the version without the leading "v", e.g. `1.0.0`
|
||||
- `{{ .Arch }}` - the system architecture in Go format, e.g. `amd64`, `arm64`
|
||||
@@ -293,7 +294,9 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
- `{{ .ArchGnu }}` - the architecture in GNU/Linux format, e.g. `x86_64`, `aarch64`
|
||||
- `{{ .OS }}` - the current operating system, e.g. `macos`, `linux`, `windows`
|
||||
|
||||
**Legacy syntax (deprecated):** The old `{tag}`, `{version}`, `{arch}`, `{arch_alias}`, `{arch_gnu}`, and `{os}` tokens are still supported but deprecated. A deprecation warning will be logged at DEBUG level when they are used.
|
||||
**Legacy syntax (deprecated):** The old `{tag}`, `{version}`, `{arch}`, `{arch_alias}`,
|
||||
`{arch_gnu}`, and `{os}` tokens are still supported but deprecated. A deprecation warning will
|
||||
be logged at DEBUG level when they are used.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -332,7 +335,6 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
```
|
||||
|
||||
- **`manifest`**
|
||||
|
||||
- **Description**: Installs an entire manifest from a local or remote file.
|
||||
- Every entry in the `install` array will be run, similar to how `steps` are run for `group`
|
||||
installers.
|
||||
@@ -350,7 +352,6 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
`master`. Ignored for local files and raw HTTP URLs.
|
||||
|
||||
- **`rsync`**
|
||||
|
||||
- **Description**: Copy files from `source` to `destination` using rsync.
|
||||
- **Options**:
|
||||
- `opts.source`: Source directory/file.
|
||||
@@ -358,7 +359,6 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
- `opts.flags`: Additional rsync flags (e.g., `--delete`, `--exclude`).
|
||||
|
||||
- **`brew`**
|
||||
|
||||
- **Description**: Installs packages using Homebrew.
|
||||
|
||||
- **Options**:
|
||||
@@ -369,7 +369,6 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
- `opts.update_flags`: Additional flags to pass only to `brew upgrade`.
|
||||
|
||||
- **`npm`/`pnpm`/`yarn`**
|
||||
|
||||
- **Description**: Installs packages using npm/pnpm/yarn.
|
||||
- Use `type: npm` for `npm install`, `type: pnpm` for `pnpm install`, and `type: yarn` for
|
||||
`yarn install`.
|
||||
@@ -379,7 +378,6 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
- `opts.update_flags`: Additional flags to pass only during update.
|
||||
|
||||
- **`apt`/`apk`**
|
||||
|
||||
- **Description**: Installs packages using apt install or apt add.
|
||||
- Use `type: apt` for `apt install`, and `type: apk` for `apk add`.
|
||||
- **Options**:
|
||||
@@ -388,7 +386,6 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
- `opts.update_flags`: Additional flags to pass only during update.
|
||||
|
||||
- **`pacman`/`yay`**
|
||||
|
||||
- **Description**: Installs packages using pacman or yay (Arch Linux).
|
||||
- Use `type: pacman` for official Arch repository packages.
|
||||
- Use `type: yay` for AUR (Arch User Repository) packages.
|
||||
@@ -400,7 +397,6 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
- `opts.update_flags`: Additional flags to pass only during update.
|
||||
|
||||
- **`pipx`**
|
||||
|
||||
- **Description**: Installs packages using pipx.
|
||||
- **Options**:
|
||||
- `opts.flags`: Additional flags to pass to commands (fallback for install/update).
|
||||
@@ -408,10 +404,8 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
- `opts.update_flags`: Additional flags to pass only to `pipx upgrade`.
|
||||
|
||||
- **`docker`**
|
||||
|
||||
- **Description**: Pulls and runs Docker containers using `docker run`. Also supports update
|
||||
checks by comparing image digests.
|
||||
|
||||
- The image is pulled from the registry (e.g., Docker Hub or GHCR) and started with the provided
|
||||
options.
|
||||
- If the container already exists, it will be started instead of run again.
|
||||
@@ -419,17 +413,14 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
- The container is always run with `--restart always -d`, unless overridden in a custom shell.
|
||||
|
||||
- **Required**:
|
||||
|
||||
- `name`: The full Docker image name, including tag (e.g.,
|
||||
`ghcr.io/open-webui/open-webui:main`).
|
||||
- `bin_name`: The container name to assign to the running instance (used in install and update
|
||||
checks).
|
||||
|
||||
- **Options**:
|
||||
|
||||
- `opts.flags`: A string of flags to pass to `docker run` (e.g., ports, volumes, extra args).
|
||||
These are appended after the default flags and before the image name.
|
||||
|
||||
- Example:
|
||||
|
||||
```yaml
|
||||
@@ -443,7 +434,6 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
|
||||
This is useful if you're running on a platform like `darwin/arm64`, but want to compare
|
||||
digests for a different image target (e.g., `linux/amd64`).
|
||||
|
||||
- Example:
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -248,9 +248,35 @@ func getBoxWidth() int {
|
||||
return boxDefaultWidth
|
||||
}
|
||||
|
||||
// CategoryDisplayMode controls how category headers are rendered.
|
||||
type CategoryDisplayMode string
|
||||
|
||||
const (
|
||||
// CategoryDisplayBorder renders categories with a full border and spacing.
|
||||
CategoryDisplayBorder CategoryDisplayMode = "border"
|
||||
// CategoryDisplayBorderCompact renders categories with a border but no spacing.
|
||||
CategoryDisplayBorderCompact CategoryDisplayMode = "border-compact"
|
||||
// CategoryDisplayMinimal renders categories without a border or spacing.
|
||||
CategoryDisplayMinimal CategoryDisplayMode = "minimal"
|
||||
)
|
||||
|
||||
// Category logs a category header with a decorative border.
|
||||
// If desc is provided, it will be displayed below the category name with auto-wrapping.
|
||||
func Category(name string, desc *string) {
|
||||
// The displayMode controls the visual style: "border" (default), "border-compact", or "minimal".
|
||||
func Category(name string, desc *string, displayMode CategoryDisplayMode) {
|
||||
switch displayMode {
|
||||
case CategoryDisplayMinimal:
|
||||
categoryMinimal(name, desc)
|
||||
case CategoryDisplayBorderCompact:
|
||||
categoryBorder(name, desc, false)
|
||||
default:
|
||||
categoryBorder(name, desc, true)
|
||||
}
|
||||
}
|
||||
|
||||
// categoryBorder renders a category with box-drawing borders.
|
||||
// If spaced is true, empty lines are added before and after.
|
||||
func categoryBorder(name string, desc *string, spaced bool) {
|
||||
boxWidth := getBoxWidth()
|
||||
innerWidth := boxWidth - 4 // Account for "│ " and " │"
|
||||
|
||||
@@ -261,7 +287,9 @@ func Category(name string, desc *string) {
|
||||
separator := boxLeftT + horizontalLine + boxRightT
|
||||
|
||||
// Log the header
|
||||
if spaced {
|
||||
Info("")
|
||||
}
|
||||
Info("%s", topBorder)
|
||||
Info("%s", formatBoxLine(name, innerWidth))
|
||||
|
||||
@@ -274,7 +302,20 @@ func Category(name string, desc *string) {
|
||||
}
|
||||
|
||||
Info("%s", bottomBorder)
|
||||
if spaced {
|
||||
Info("")
|
||||
}
|
||||
}
|
||||
|
||||
// categoryMinimal renders a category as plain text without borders or spacing.
|
||||
func categoryMinimal(name string, desc *string) {
|
||||
Info("%s", name)
|
||||
if desc != nil && len(*desc) > 0 {
|
||||
boxWidth := getBoxWidth()
|
||||
for _, line := range wrapText(*desc, boxWidth) {
|
||||
Info("%s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatBoxLine formats a line of text to fit within the box.
|
||||
|
||||
2
main.go
2
main.go
@@ -142,7 +142,7 @@ func runMain(cliConfig *appconfig.AppCliConfig) {
|
||||
|
||||
// Handle category entries - just log the header
|
||||
if item.isCategory {
|
||||
logger.Category(*item.data.Category, item.data.Desc)
|
||||
logger.Category(*item.data.Category, item.data.Desc, logger.CategoryDisplayMode(cfg.GetCategoryDisplay()))
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user