mirror of
https://github.com/yusing/godoxy.git
synced 2026-04-24 17:28:31 +02:00
docs: add per package README for implementation details (AI generated with human review)
This commit is contained in:
358
internal/homepage/README.md
Normal file
358
internal/homepage/README.md
Normal file
@@ -0,0 +1,358 @@
|
||||
# Homepage
|
||||
|
||||
The homepage package provides the GoDoxy WebUI dashboard with support for categories, favorites, widgets, and dynamic item configuration.
|
||||
|
||||
## Overview
|
||||
|
||||
The homepage package implements the WebUI dashboard, managing homepage items, categories, sorting methods, and widget integration for monitoring container status and providing interactive features.
|
||||
|
||||
### Key Features
|
||||
|
||||
- Dynamic homepage item management
|
||||
- Category-based organization (All, Favorites, Hidden, Others)
|
||||
- Multiple sort methods (clicks, alphabetical, custom)
|
||||
- Widget support for live data display
|
||||
- Icon URL handling with favicon integration
|
||||
- Item override configuration
|
||||
- Click tracking and statistics
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[HomepageMap] --> B{Category Management}
|
||||
B --> C[All]
|
||||
B --> D[Favorites]
|
||||
B --> E[Hidden]
|
||||
B --> F[Others]
|
||||
|
||||
G[Item] --> H[ItemConfig]
|
||||
H --> I[Widget Config]
|
||||
H --> J[Icon]
|
||||
H --> K[Category]
|
||||
|
||||
L[Widgets] --> M[HTTP Widget]
|
||||
N[Sorting] --> O[Clicks]
|
||||
N --> P[Alphabetical]
|
||||
N --> Q[Custom]
|
||||
```
|
||||
|
||||
## Core Types
|
||||
|
||||
### Homepage Structure
|
||||
|
||||
```go
|
||||
type HomepageMap struct {
|
||||
ordered.Map[string, *Category]
|
||||
}
|
||||
|
||||
type Homepage []*Category
|
||||
|
||||
type Category struct {
|
||||
Items []*Item
|
||||
Name string
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
ItemConfig
|
||||
SortOrder int
|
||||
FavSortOrder int
|
||||
AllSortOrder int
|
||||
Clicks int
|
||||
Widgets []Widget
|
||||
Alias string
|
||||
Provider string
|
||||
OriginURL string
|
||||
ContainerID string
|
||||
}
|
||||
|
||||
type ItemConfig struct {
|
||||
Show bool
|
||||
Name string
|
||||
Icon *IconURL
|
||||
Category string
|
||||
Description string
|
||||
URL string
|
||||
Favorite bool
|
||||
WidgetConfig *widgets.Config
|
||||
}
|
||||
```
|
||||
|
||||
### Sort Methods
|
||||
|
||||
```go
|
||||
const (
|
||||
SortMethodClicks = "clicks"
|
||||
SortMethodAlphabetical = "alphabetical"
|
||||
SortMethodCustom = "custom"
|
||||
)
|
||||
```
|
||||
|
||||
### Categories
|
||||
|
||||
```go
|
||||
const (
|
||||
CategoryAll = "All"
|
||||
CategoryFavorites = "Favorites"
|
||||
CategoryHidden = "Hidden"
|
||||
CategoryOthers = "Others"
|
||||
)
|
||||
```
|
||||
|
||||
## Public API
|
||||
|
||||
### Creation
|
||||
|
||||
```go
|
||||
// NewHomepageMap creates a new homepage map with default categories.
|
||||
func NewHomepageMap(total int) *HomepageMap
|
||||
```
|
||||
|
||||
### Item Management
|
||||
|
||||
```go
|
||||
// Add adds an item to appropriate categories.
|
||||
func (c *HomepageMap) Add(item *Item)
|
||||
|
||||
// GetOverride returns the override configuration for an item.
|
||||
func (cfg Item) GetOverride() Item
|
||||
```
|
||||
|
||||
### Sorting
|
||||
|
||||
```go
|
||||
// Sort sorts a category by the specified method.
|
||||
func (c *Category) Sort(method SortMethod)
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating a Homepage Map
|
||||
|
||||
```go
|
||||
homepageMap := homepage.NewHomepageMap(100) // Reserve space for 100 items
|
||||
```
|
||||
|
||||
### Adding Items
|
||||
|
||||
```go
|
||||
item := &homepage.Item{
|
||||
Alias: "my-app",
|
||||
Provider: "docker",
|
||||
OriginURL: "http://myapp.local",
|
||||
ItemConfig: homepage.ItemConfig{
|
||||
Name: "My Application",
|
||||
Show: true,
|
||||
Favorite: true,
|
||||
Category: "Docker",
|
||||
Description: "My Docker application",
|
||||
},
|
||||
}
|
||||
|
||||
homepageMap.Add(item)
|
||||
```
|
||||
|
||||
### Sorting Categories
|
||||
|
||||
```go
|
||||
allCategory := homepageMap.Get(homepage.CategoryAll)
|
||||
if allCategory != nil {
|
||||
allCategory.Sort(homepage.SortMethodClicks)
|
||||
}
|
||||
```
|
||||
|
||||
### Filtering by Category
|
||||
|
||||
```go
|
||||
favorites := homepageMap.Get(homepage.CategoryFavorites)
|
||||
for _, item := range favorites.Items {
|
||||
fmt.Printf("Favorite: %s\n", item.Name)
|
||||
}
|
||||
```
|
||||
|
||||
## Widgets
|
||||
|
||||
The homepage supports widgets for each item:
|
||||
|
||||
```go
|
||||
type Widget struct {
|
||||
Label string
|
||||
Value string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
// Widget configuration
|
||||
}
|
||||
```
|
||||
|
||||
### Widget Types
|
||||
|
||||
Widgets can display various types of information:
|
||||
|
||||
- **Status**: Container health status
|
||||
- **Stats**: Usage statistics
|
||||
- **Links**: Quick access links
|
||||
- **Custom**: Provider-specific data
|
||||
|
||||
## Icon Handling
|
||||
|
||||
Icons are handled via `IconURL` type:
|
||||
|
||||
```go
|
||||
type IconURL struct {
|
||||
// Icon URL with various sources
|
||||
}
|
||||
|
||||
// Automatic favicon fetching from item URL
|
||||
```
|
||||
|
||||
## Categories
|
||||
|
||||
### Default Categories
|
||||
|
||||
| Category | Description |
|
||||
| --------- | ------------------------ |
|
||||
| All | Contains all items |
|
||||
| Favorites | User-favorited items |
|
||||
| Hidden | Items with `Show: false` |
|
||||
| Others | Uncategorized items |
|
||||
|
||||
### Custom Categories
|
||||
|
||||
Custom categories are created dynamically:
|
||||
|
||||
```go
|
||||
// Adding to custom category
|
||||
item := &homepage.Item{
|
||||
ItemConfig: homepage.ItemConfig{
|
||||
Name: "App",
|
||||
Category: "Development",
|
||||
},
|
||||
}
|
||||
homepageMap.Add(item)
|
||||
// "Development" category is auto-created
|
||||
```
|
||||
|
||||
## Override Configuration
|
||||
|
||||
Items can have override configurations for customization:
|
||||
|
||||
```go
|
||||
// GetOverride returns the effective configuration
|
||||
func (cfg Item) GetOverride() Item {
|
||||
return overrideConfigInstance.GetOverride(cfg)
|
||||
}
|
||||
```
|
||||
|
||||
## Sorting Methods
|
||||
|
||||
### Clicks Sort
|
||||
|
||||
Sorts by click count (most clicked first):
|
||||
|
||||
```go
|
||||
func (c *Category) sortByClicks() {
|
||||
slices.SortStableFunc(c.Items, func(a, b *Item) int {
|
||||
if a.Clicks > b.Clicks {
|
||||
return -1
|
||||
}
|
||||
if a.Clicks < b.Clicks {
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(title(a.Name), title(b.Name))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Alphabetical Sort
|
||||
|
||||
Sorts alphabetically by name:
|
||||
|
||||
```go
|
||||
func (c *Category) sortByAlphabetical() {
|
||||
slices.SortStableFunc(c.Items, func(a, b *Item) int {
|
||||
return strings.Compare(title(a.Name), title(b.Name))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Sort
|
||||
|
||||
Sorts by predefined sort order:
|
||||
|
||||
```go
|
||||
func (c *Category) sortByCustom() {
|
||||
// Uses SortOrder, FavSortOrder, AllSortOrder fields
|
||||
}
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant RouteProvider
|
||||
participant HomepageMap
|
||||
participant Category
|
||||
participant Widget
|
||||
|
||||
RouteProvider->>HomepageMap: Add(Item)
|
||||
HomepageMap->>HomepageMap: Add to All
|
||||
HomepageMap->>HomepageMap: Add to Category
|
||||
alt Item.Favorite
|
||||
HomepageMap->>CategoryFavorites: Add item
|
||||
else !Item.Show
|
||||
HomepageMap->>CategoryHidden: Add item
|
||||
end
|
||||
|
||||
User->>HomepageMap: Get Category
|
||||
HomepageMap-->>User: Items
|
||||
|
||||
User->>Category: Sort(method)
|
||||
Category-->>User: Sorted Items
|
||||
|
||||
User->>Item: Get Widgets
|
||||
Item->>Widget: Fetch Data
|
||||
Widget-->>Item: Widget Data
|
||||
Item-->>User: Display Widgets
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
The homepage package integrates with:
|
||||
|
||||
- **Route Provider**: Item discovery from routes
|
||||
- **Container**: Container status and metadata
|
||||
- **Widgets**: Live data display
|
||||
- **API**: Frontend data API
|
||||
- **Configuration**: Default and override configs
|
||||
|
||||
## Configuration
|
||||
|
||||
### Active Configuration
|
||||
|
||||
```go
|
||||
var ActiveConfig atomic.Pointer[Config]
|
||||
```
|
||||
|
||||
### Configuration Structure
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
UseDefaultCategories bool
|
||||
// ... other options
|
||||
}
|
||||
```
|
||||
|
||||
## Serialization
|
||||
|
||||
The package registers default value factories for serialization:
|
||||
|
||||
```go
|
||||
func init() {
|
||||
serialization.RegisterDefaultValueFactory(func() *ItemConfig {
|
||||
return &ItemConfig{
|
||||
Show: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
227
internal/homepage/integrations/qbittorrent/README.md
Normal file
227
internal/homepage/integrations/qbittorrent/README.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# qBittorrent Integration Package
|
||||
|
||||
This package provides a qBittorrent widget for the GoDoxy homepage dashboard, enabling real-time monitoring of torrent status and transfer statistics.
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> This package is a work in progress and is not stable.
|
||||
|
||||
## Overview
|
||||
|
||||
The `internal/homepage/integrations/qbittorrent` package implements the `widgets.Widget` interface for qBittorrent. It provides functionality to connect to a qBittorrent instance and fetch transfer information.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
```
|
||||
integrations/qbittorrent/
|
||||
├── client.go # Client and API methods
|
||||
├── transfer_info.go # Transfer info widget data
|
||||
└── version.go # Version checking
|
||||
└── logs.go # Log fetching
|
||||
```
|
||||
|
||||
### Main Types
|
||||
|
||||
```go
|
||||
type Client struct {
|
||||
URL string
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Client Methods
|
||||
|
||||
#### Initialize
|
||||
|
||||
Connects to the qBittorrent API and verifies authentication.
|
||||
|
||||
```go
|
||||
func (c *Client) Initialize(ctx context.Context, url string, cfg map[string]any) error
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `ctx` - Context for the HTTP request
|
||||
- `url` - Base URL of the qBittorrent instance
|
||||
- `cfg` - Configuration map containing `username` and `password`
|
||||
|
||||
**Returns:**
|
||||
|
||||
- `error` - Connection or authentication error
|
||||
|
||||
**Example:**
|
||||
|
||||
```go
|
||||
client := &qbittorrent.Client{}
|
||||
err := client.Initialize(ctx, "http://localhost:8080", map[string]any{
|
||||
"username": "admin",
|
||||
"password": "your-password",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
#### Data
|
||||
|
||||
Returns current transfer statistics as name-value pairs.
|
||||
|
||||
```go
|
||||
func (c *Client) Data(ctx context.Context) ([]widgets.NameValue, error)
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
|
||||
- `[]widgets.NameValue` - Transfer statistics
|
||||
- `error` - API request error
|
||||
|
||||
**Example:**
|
||||
|
||||
```go
|
||||
data, err := client.Data(ctx)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, nv := range data {
|
||||
fmt.Printf("%s: %s\n", nv.Name, nv.Value)
|
||||
}
|
||||
// Output:
|
||||
// Status: connected
|
||||
// Download: 1.5 GB
|
||||
// Upload: 256 MB
|
||||
// Download Speed: 5.2 MB/s
|
||||
// Upload Speed: 1.1 MB/s
|
||||
```
|
||||
|
||||
### Internal Methods
|
||||
|
||||
#### doRequest
|
||||
|
||||
Performs an HTTP request to the qBittorrent API.
|
||||
|
||||
```go
|
||||
func (c *Client) doRequest(ctx context.Context, method, endpoint string, query url.Values, body io.Reader) (*http.Response, error)
|
||||
```
|
||||
|
||||
#### jsonRequest
|
||||
|
||||
Performs a JSON API request and unmarshals the response.
|
||||
|
||||
```go
|
||||
func jsonRequest[T any](ctx context.Context, client *Client, endpoint string, query url.Values) (result T, err error)
|
||||
```
|
||||
|
||||
## Data Types
|
||||
|
||||
### TransferInfo
|
||||
|
||||
Represents transfer statistics from qBittorrent.
|
||||
|
||||
```go
|
||||
type TransferInfo struct {
|
||||
ConnectionStatus string `json:"connection_status"`
|
||||
SessionDownloads uint64 `json:"dl_info_data"`
|
||||
SessionUploads uint64 `json:"up_info_data"`
|
||||
DownloadSpeed uint64 `json:"dl_info_speed"`
|
||||
UploadSpeed uint64 `json:"up_info_speed"`
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| ----------------------- | ------ | ----------------------- |
|
||||
| `/api/v2/transfer/info` | GET | Get transfer statistics |
|
||||
| `/api/v2/app/version` | GET | Get qBittorrent version |
|
||||
|
||||
## Usage Example
|
||||
|
||||
### Complete Widget Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/yusing/godoxy/internal/homepage/integrations/qbittorrent"
|
||||
"github.com/yusing/godoxy/internal/homepage/widgets"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create and initialize client
|
||||
client := &qbittorrent.Client{}
|
||||
err := client.Initialize(ctx, "http://localhost:8080", map[string]any{
|
||||
"username": "admin",
|
||||
"password": "password123",
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("Connection failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get transfer data
|
||||
data, err := client.Data(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to get data: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Display in dashboard format
|
||||
fmt.Println("qBittorrent Status:")
|
||||
fmt.Println(strings.Repeat("-", 30))
|
||||
for _, nv := range data {
|
||||
fmt.Printf(" %-15s %s\n", nv.Name+":", nv.Value)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Homepage Widgets
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Homepage Dashboard] --> B[Widget Config]
|
||||
B --> C{qBittorrent Provider}
|
||||
C --> D[Create Client]
|
||||
D --> E[Initialize with credentials]
|
||||
E --> F[Fetch Transfer Info]
|
||||
F --> G[Format as NameValue pairs]
|
||||
G --> H[Render in UI]
|
||||
```
|
||||
|
||||
### Widget Configuration
|
||||
|
||||
```yaml
|
||||
widgets:
|
||||
- provider: qbittorrent
|
||||
config:
|
||||
url: http://localhost:8080
|
||||
username: admin
|
||||
password: password123
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```go
|
||||
// Handle HTTP errors
|
||||
resp, err := client.doRequest(ctx, http.MethodGet, endpoint, query, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, widgets.ErrHTTPStatus.Subject(resp.Status)
|
||||
}
|
||||
```
|
||||
|
||||
## Related Packages
|
||||
|
||||
- `internal/homepage/widgets` - Widget framework and interface
|
||||
- `github.com/bytedance/sonic` - JSON serialization
|
||||
- `github.com/yusing/goutils/strings` - String utilities for formatting
|
||||
188
internal/homepage/widgets/README.md
Normal file
188
internal/homepage/widgets/README.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Homepage Widgets Package
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> This package is a work in progress and is not stable.
|
||||
|
||||
This package provides a widget framework for the GoDoxy homepage dashboard, enabling integration with various service providers to display real-time data.
|
||||
|
||||
## Overview
|
||||
|
||||
The `internal/homepage/widgets` package defines the widget interface and common utilities for building homepage widgets. It provides a standardized way to integrate external services into the homepage dashboard.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
```
|
||||
widgets/
|
||||
├── widgets.go # Widget interface and config
|
||||
└── http.go # HTTP client and error definitions
|
||||
```
|
||||
|
||||
### Data Types
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Provider string `json:"provider"`
|
||||
Config Widget `json:"config"`
|
||||
}
|
||||
|
||||
type Widget interface {
|
||||
Initialize(ctx context.Context, url string, cfg map[string]any) error
|
||||
Data(ctx context.Context) ([]NameValue, error)
|
||||
}
|
||||
|
||||
type NameValue struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
```
|
||||
|
||||
### Constants
|
||||
|
||||
```go
|
||||
const (
|
||||
WidgetProviderQbittorrent = "qbittorrent"
|
||||
)
|
||||
```
|
||||
|
||||
### Errors
|
||||
|
||||
```go
|
||||
var ErrInvalidProvider = gperr.New("invalid provider")
|
||||
var ErrHTTPStatus = gperr.New("http status")
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Widget Interface
|
||||
|
||||
```go
|
||||
type Widget interface {
|
||||
// Initialize sets up the widget with connection configuration
|
||||
Initialize(ctx context.Context, url string, cfg map[string]any) error
|
||||
|
||||
// Data returns current widget data as name-value pairs
|
||||
Data(ctx context.Context) ([]NameValue, error)
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
#### Config.UnmarshalMap
|
||||
|
||||
Parses widget configuration from a map.
|
||||
|
||||
```go
|
||||
func (cfg *Config) UnmarshalMap(m map[string]any) error
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `m` - Map containing `provider` and `config` keys
|
||||
|
||||
**Returns:**
|
||||
|
||||
- `error` - Parsing or validation error
|
||||
|
||||
**Example:**
|
||||
|
||||
```go
|
||||
widgetCfg := widgets.Config{}
|
||||
err := widgetCfg.UnmarshalMap(map[string]any{
|
||||
"provider": "qbittorrent",
|
||||
"config": map[string]any{
|
||||
"username": "admin",
|
||||
"password": "password123",
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### HTTP Client
|
||||
|
||||
```go
|
||||
var HTTPClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
```
|
||||
|
||||
### Available Providers
|
||||
|
||||
- **qbittorrent** - qBittorrent torrent client integration (WIP)
|
||||
|
||||
## Usage Example
|
||||
|
||||
### Creating a Custom Widget
|
||||
|
||||
```go
|
||||
package mywidget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/yusing/godoxy/internal/homepage/widgets"
|
||||
)
|
||||
|
||||
type MyWidget struct {
|
||||
URL string
|
||||
APIKey string
|
||||
}
|
||||
|
||||
func (m *MyWidget) Initialize(ctx context.Context, url string, cfg map[string]any) error {
|
||||
m.URL = url
|
||||
m.APIKey = cfg["api_key"].(string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MyWidget) Data(ctx context.Context) ([]widgets.NameValue, error) {
|
||||
// Fetch data and return as name-value pairs
|
||||
return []widgets.NameValue{
|
||||
{Name: "Status", Value: "Online"},
|
||||
{Name: "Uptime", Value: "24h"},
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Registering the Widget
|
||||
|
||||
```go
|
||||
// In widgets initialization
|
||||
widgetProviders["mywidget"] = struct{}{}
|
||||
```
|
||||
|
||||
### Using the Widget in Homepage
|
||||
|
||||
```go
|
||||
// Fetch widget data
|
||||
widget := getWidget("qbittorrent")
|
||||
data, err := widget.Data(ctx)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Display data
|
||||
for _, nv := range data {
|
||||
fmt.Printf("%s: %s\n", nv.Name, nv.Value)
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Homepage
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Homepage Dashboard] --> B[Widget Config]
|
||||
B --> C[Widget Factory]
|
||||
C --> D{Provider Type}
|
||||
D -->|qbittorrent| E[qBittorrent Widget]
|
||||
D -->|custom| F[Custom Widget]
|
||||
E --> G[Initialize]
|
||||
F --> G
|
||||
G --> H[Data Fetch]
|
||||
H --> I[Render UI]
|
||||
```
|
||||
|
||||
## Related Packages
|
||||
|
||||
- `internal/homepage/integrations/qbittorrent` - qBittorrent widget implementation
|
||||
- `internal/serialization` - Configuration unmarshaling utilities
|
||||
- `github.com/yusing/goutils/errs` - Error handling
|
||||
Reference in New Issue
Block a user