discordo/internal/config/config.go
2026-09-28 18:51:38 -04:00

251 lines
6.3 KiB
Go

package config
import (
_ "embed"
"fmt"
"log/slog"
"mime"
"os"
"path"
"path/filepath"
"slices"
"sync"
"unicode/utf8"
"github.com/BurntSushi/toml"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/discordo/internal/consts"
)
const fileName = "config.toml"
type MIMETypes []string
func (types *MIMETypes) UnmarshalTOML(value any) error {
values, ok := value.([]any)
if !ok {
return errInvalidType
}
parsed := make(MIMETypes, 0, len(values))
for _, value := range values {
mediaType, ok := value.(string)
if !ok {
return errInvalidType
}
mediaType, _, err := mime.ParseMediaType(mediaType)
if err != nil {
return err
}
parsed = append(parsed, mediaType)
}
*types = parsed
return nil
}
func (types MIMETypes) Has(mediaType string) bool {
mediaType, _, err := mime.ParseMediaType(mediaType)
if err != nil {
return false
}
return slices.ContainsFunc(types, func(pattern string) bool {
matches, _ := path.Match(pattern, mediaType)
return matches
})
}
type (
Timestamps struct {
Enabled bool `toml:"enabled"`
Format string `toml:"format"`
}
DateSeparator struct {
Enabled bool `toml:"enabled"`
Format string `toml:"format"`
Character string `toml:"character"`
}
Notifications struct {
Enabled bool `toml:"enabled"`
WhenUnfocused bool `toml:"when_unfocused"`
}
TypingIndicator struct {
Send bool `toml:"send"`
Receive bool `toml:"receive"`
}
Icons struct {
GuildCategory string `toml:"guild_category"`
GuildText string `toml:"guild_text"`
GuildVoice string `toml:"guild_voice"`
GuildStageVoice string `toml:"guild_stage_voice"`
GuildAnnouncementThread string `toml:"guild_announcement_thread"`
GuildPublicThread string `toml:"guild_public_thread"`
GuildPrivateThread string `toml:"guild_private_thread"`
GuildAnnouncement string `toml:"guild_announcement"`
GuildForum string `toml:"guild_forum"`
GuildStore string `toml:"guild_store"`
}
PickerConfig struct {
Width int `toml:"width"`
Height int `toml:"height"`
}
MarkdownConfig struct {
Enabled bool `toml:"enabled"`
MaskSpoilers bool `toml:"mask_spoilers"`
Theme string `toml:"theme"`
}
HelpConfig struct {
Enabled bool `toml:"enabled"`
CompactModifiers bool `toml:"compact_modifiers"`
Padding [2]int `toml:"padding"`
Separator string `toml:"separator"`
}
ComposerConfig struct {
// MaxHeight caps how tall (in newline-separated rows) the input grows before it starts scrolling internally.
// Must be >= 1; values <= 0 fall back to the default.
MaxHeight int `toml:"max_height"`
}
SidebarMarkersConfig struct {
Expanded string `toml:"expanded"`
Collapsed string `toml:"collapsed"`
Leaf string `toml:"leaf"`
}
SidebarIndentsConfig struct {
Guild int `toml:"guild"`
Category int `toml:"category"`
Channel int `toml:"channel"`
Forum int `toml:"forum"`
GroupDM int `toml:"group_dm"`
DM int `toml:"dm"`
}
// PaneConfig is a side pane that can be toggled at runtime.
PaneConfig struct {
// Visible controls whether the pane is shown by default at start-up.
Visible bool `toml:"visible"`
// WidthPercent is the percentage of the total window width that the pane occupies.
WidthPercent int `toml:"width_percent"`
}
SidebarConfig struct {
PaneConfig
Markers SidebarMarkersConfig `toml:"markers"`
Indents SidebarIndentsConfig `toml:"indents"`
}
Config struct {
AutoFocus bool `toml:"auto_focus"`
Mouse bool `toml:"mouse"`
Editor string `toml:"editor"`
Status discord.Status `toml:"status"`
HideBlockedUsers bool `toml:"hide_blocked_users"`
ShowAttachmentLinks bool `toml:"show_attachment_links"`
AllowedMIMETypes MIMETypes `toml:"allowed_mime_types"`
// Use 0 to disable
AutocompleteLimit uint8 `toml:"autocomplete_limit"`
MessagesLimit uint8 `toml:"messages_limit"`
Markdown MarkdownConfig `toml:"markdown"`
Help HelpConfig `toml:"help"`
Picker PickerConfig `toml:"picker"`
Timestamps Timestamps `toml:"timestamps"`
DateSeparator DateSeparator `toml:"date_separator"`
Notifications Notifications `toml:"notifications"`
TypingIndicator TypingIndicator `toml:"typing_indicator"`
Sidebar SidebarConfig `toml:"sidebar"`
MembersTree PaneConfig `toml:"members_tree"`
Composer ComposerConfig `toml:"composer"`
Icons Icons `toml:"icons"`
Keybinds Keybinds `toml:"keybinds"`
Theme Theme `toml:"theme"`
}
)
//go:embed config.toml
var defaultCfg []byte
var dir = sync.OnceValue(func() string {
path, err := os.UserConfigDir()
if err != nil {
slog.Info("user config dir cannot be determined; falling back to the current dir", "err", err)
path = "."
}
return filepath.Join(path, consts.Name)
})
func DefaultPath() string {
return filepath.Join(dir(), fileName)
}
// Load reads the configuration file and parses it.
func Load(path string) (*Config, error) {
cfg := Config{
Keybinds: defaultKeybinds(),
}
if err := toml.Unmarshal(defaultCfg, &cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal default config: %w", err)
}
file, err := os.Open(path)
switch {
case os.IsNotExist(err):
slog.Info("config file does not exist, falling back to the default config", "path", path, "err", err)
case err != nil:
return nil, fmt.Errorf("failed to open config file: %w", err)
default:
defer file.Close()
if _, err := toml.NewDecoder(file).Decode(&cfg); err != nil {
return nil, fmt.Errorf("failed to decode config: %w", err)
}
}
cfg.applyDefaults()
return &cfg, nil
}
func (cfg *Config) applyDefaults() {
if cfg.Editor == "default" {
cfg.Editor = os.Getenv("EDITOR")
}
if cfg.Status == "default" {
cfg.Status = discord.UnknownStatus
}
if cfg.Composer.MaxHeight <= 0 {
cfg.Composer.MaxHeight = 10
}
for _, pane := range []*PaneConfig{&cfg.Sidebar.PaneConfig, &cfg.MembersTree} {
if pane.WidthPercent <= 0 || pane.WidthPercent >= 100 {
pane.WidthPercent = 20
}
}
if cfg.DateSeparator.Format == "" {
cfg.DateSeparator.Format = "January 2, 2006"
}
if r, _ := utf8.DecodeRuneInString(cfg.DateSeparator.Character); r == utf8.RuneError {
cfg.DateSeparator.Character = "─"
} else {
cfg.DateSeparator.Character = string(r)
}
}