WitNote / internal /config /editor.go
AUXteam's picture
Upload folder using huggingface_hub
6a7089a verified
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// SetConfigValue sets a dotted path in FileConfig (e.g., "server.port", "instanceDefaults.mode").
// Returns the updated FileConfig and any error.
func SetConfigValue(fc *FileConfig, path string, value string) error {
parts := strings.SplitN(path, ".", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid path %q (expected section.field, e.g., server.port)", path)
}
section, field := parts[0], parts[1]
switch section {
case "server":
return setServerField(&fc.Server, field, value)
case "browser":
return setBrowserField(&fc.Browser, field, value)
case "instanceDefaults":
return setInstanceDefaultsField(&fc.InstanceDefaults, field, value)
case "security":
return setSecurityField(&fc.Security, field, value)
case "profiles":
return setProfilesField(&fc.Profiles, field, value)
case "multiInstance":
return setMultiInstanceField(&fc.MultiInstance, field, value)
case "timeouts":
return setTimeoutsField(&fc.Timeouts, field, value)
default:
return fmt.Errorf("unknown section %q (valid: server, browser, instanceDefaults, security, profiles, multiInstance, timeouts)", section)
}
}
func setServerField(s *ServerConfig, field, value string) error {
switch field {
case "port":
s.Port = value
case "bind":
s.Bind = value
case "token":
s.Token = value
case "stateDir":
s.StateDir = value
default:
return fmt.Errorf("unknown field server.%s", field)
}
return nil
}
func setBrowserField(b *BrowserConfig, field, value string) error {
switch field {
case "version":
b.ChromeVersion = value
case "binary":
b.ChromeBinary = value
case "extraFlags":
b.ChromeExtraFlags = value
default:
return fmt.Errorf("unknown field browser.%s", field)
}
return nil
}
func setInstanceDefaultsField(c *InstanceDefaultsConfig, field, value string) error {
switch field {
case "mode":
c.Mode = value
case "noRestore":
b, err := parseBool(value)
if err != nil {
return fmt.Errorf("instanceDefaults.noRestore: %w", err)
}
c.NoRestore = &b
case "timezone":
c.Timezone = value
case "blockImages":
b, err := parseBool(value)
if err != nil {
return fmt.Errorf("instanceDefaults.blockImages: %w", err)
}
c.BlockImages = &b
case "blockMedia":
b, err := parseBool(value)
if err != nil {
return fmt.Errorf("instanceDefaults.blockMedia: %w", err)
}
c.BlockMedia = &b
case "blockAds":
b, err := parseBool(value)
if err != nil {
return fmt.Errorf("instanceDefaults.blockAds: %w", err)
}
c.BlockAds = &b
case "maxTabs":
n, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("instanceDefaults.maxTabs must be a number: %w", err)
}
c.MaxTabs = &n
case "maxParallelTabs":
n, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("instanceDefaults.maxParallelTabs must be a number: %w", err)
}
c.MaxParallelTabs = &n
case "userAgent":
c.UserAgent = value
case "noAnimations":
b, err := parseBool(value)
if err != nil {
return fmt.Errorf("instanceDefaults.noAnimations: %w", err)
}
c.NoAnimations = &b
case "stealthLevel":
c.StealthLevel = value
case "tabEvictionPolicy":
c.TabEvictionPolicy = value
default:
return fmt.Errorf("unknown field instanceDefaults.%s", field)
}
return nil
}
func setSecurityField(s *SecurityConfig, field, value string) error {
if strings.HasPrefix(field, "attach.") {
return setAttachField(&s.Attach, strings.TrimPrefix(field, "attach."), value)
}
if strings.HasPrefix(field, "idpi.") {
return setIDPIField(&s.IDPI, strings.TrimPrefix(field, "idpi."), value)
}
b, err := parseBool(value)
if err != nil {
return fmt.Errorf("security.%s: %w", field, err)
}
switch field {
case "allowEvaluate":
s.AllowEvaluate = &b
case "allowMacro":
s.AllowMacro = &b
case "allowScreencast":
s.AllowScreencast = &b
case "allowDownload":
s.AllowDownload = &b
case "allowUpload":
s.AllowUpload = &b
default:
return fmt.Errorf("unknown field security.%s", field)
}
return nil
}
func setProfilesField(p *ProfilesConfig, field, value string) error {
switch field {
case "baseDir":
p.BaseDir = value
case "defaultProfile":
p.DefaultProfile = value
default:
return fmt.Errorf("unknown field profiles.%s", field)
}
return nil
}
func setMultiInstanceField(o *MultiInstanceConfig, field, value string) error {
if strings.HasPrefix(field, "restart.") {
return setMultiInstanceRestartField(&o.Restart, strings.TrimPrefix(field, "restart."), value)
}
switch field {
case "strategy":
o.Strategy = value
case "allocationPolicy":
o.AllocationPolicy = value
case "instancePortStart":
n, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("multiInstance.instancePortStart must be a number: %w", err)
}
o.InstancePortStart = &n
case "instancePortEnd":
n, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("multiInstance.instancePortEnd must be a number: %w", err)
}
o.InstancePortEnd = &n
default:
return fmt.Errorf("unknown field multiInstance.%s", field)
}
return nil
}
func setMultiInstanceRestartField(r *MultiInstanceRestartConfig, field, value string) error {
n, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("multiInstance.restart.%s must be a number: %w", field, err)
}
switch field {
case "maxRestarts":
r.MaxRestarts = &n
case "initBackoffSec":
r.InitBackoffSec = &n
case "maxBackoffSec":
r.MaxBackoffSec = &n
case "stableAfterSec":
r.StableAfterSec = &n
default:
return fmt.Errorf("unknown field multiInstance.restart.%s", field)
}
return nil
}
func setTimeoutsField(t *TimeoutsConfig, field, value string) error {
n, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("timeouts.%s must be a number: %w", field, err)
}
switch field {
case "actionSec":
t.ActionSec = n
case "navigateSec":
t.NavigateSec = n
case "shutdownSec":
t.ShutdownSec = n
case "waitNavMs":
t.WaitNavMs = n
default:
return fmt.Errorf("unknown field timeouts.%s", field)
}
return nil
}
func setAttachField(a *AttachConfig, field, value string) error {
switch field {
case "enabled":
b, err := parseBool(value)
if err != nil {
return fmt.Errorf("security.attach.enabled: %w", err)
}
a.Enabled = &b
case "allowHosts":
a.AllowHosts = parseCSVList(value)
case "allowSchemes":
a.AllowSchemes = parseCSVList(value)
default:
return fmt.Errorf("unknown field security.attach.%s", field)
}
return nil
}
// GetConfigValue reads a dotted-path field from FileConfig and returns its string representation.
// The path format matches SetConfigValue (e.g., "server.port", "security.attach.allowHosts").
// Pointer fields that are unset return an empty string.
// Slice fields are returned as comma-separated values.
func GetConfigValue(fc *FileConfig, path string) (string, error) {
parts := strings.SplitN(path, ".", 2)
if len(parts) != 2 {
return "", fmt.Errorf("invalid path %q (expected section.field, e.g., server.port)", path)
}
section, field := parts[0], parts[1]
switch section {
case "server":
return getServerField(&fc.Server, field)
case "browser":
return getBrowserField(&fc.Browser, field)
case "instanceDefaults":
return getInstanceDefaultsField(&fc.InstanceDefaults, field)
case "security":
return getSecurityField(&fc.Security, field)
case "profiles":
return getProfilesField(&fc.Profiles, field)
case "multiInstance":
return getMultiInstanceField(&fc.MultiInstance, field)
case "timeouts":
return getTimeoutsField(&fc.Timeouts, field)
default:
return "", fmt.Errorf("unknown section %q (valid: server, browser, instanceDefaults, security, profiles, multiInstance, timeouts)", section)
}
}
func getServerField(s *ServerConfig, field string) (string, error) {
switch field {
case "port":
return s.Port, nil
case "bind":
return s.Bind, nil
case "token":
return s.Token, nil
case "stateDir":
return s.StateDir, nil
default:
return "", fmt.Errorf("unknown field server.%s", field)
}
}
func getBrowserField(b *BrowserConfig, field string) (string, error) {
switch field {
case "version":
return b.ChromeVersion, nil
case "binary":
return b.ChromeBinary, nil
case "extraFlags":
return b.ChromeExtraFlags, nil
default:
return "", fmt.Errorf("unknown field browser.%s", field)
}
}
func getInstanceDefaultsField(c *InstanceDefaultsConfig, field string) (string, error) {
switch field {
case "mode":
return c.Mode, nil
case "noRestore":
return formatBoolPtr(c.NoRestore), nil
case "timezone":
return c.Timezone, nil
case "blockImages":
return formatBoolPtr(c.BlockImages), nil
case "blockMedia":
return formatBoolPtr(c.BlockMedia), nil
case "blockAds":
return formatBoolPtr(c.BlockAds), nil
case "maxTabs":
return formatIntPtr(c.MaxTabs), nil
case "maxParallelTabs":
return formatIntPtr(c.MaxParallelTabs), nil
case "userAgent":
return c.UserAgent, nil
case "noAnimations":
return formatBoolPtr(c.NoAnimations), nil
case "stealthLevel":
return c.StealthLevel, nil
case "tabEvictionPolicy":
return c.TabEvictionPolicy, nil
default:
return "", fmt.Errorf("unknown field instanceDefaults.%s", field)
}
}
func getSecurityField(s *SecurityConfig, field string) (string, error) {
if strings.HasPrefix(field, "attach.") {
return getAttachField(&s.Attach, strings.TrimPrefix(field, "attach."))
}
if strings.HasPrefix(field, "idpi.") {
return getIDPIField(&s.IDPI, strings.TrimPrefix(field, "idpi."))
}
switch field {
case "allowEvaluate":
return formatBoolPtr(s.AllowEvaluate), nil
case "allowMacro":
return formatBoolPtr(s.AllowMacro), nil
case "allowScreencast":
return formatBoolPtr(s.AllowScreencast), nil
case "allowDownload":
return formatBoolPtr(s.AllowDownload), nil
case "allowUpload":
return formatBoolPtr(s.AllowUpload), nil
default:
return "", fmt.Errorf("unknown field security.%s", field)
}
}
func getProfilesField(p *ProfilesConfig, field string) (string, error) {
switch field {
case "baseDir":
return p.BaseDir, nil
case "defaultProfile":
return p.DefaultProfile, nil
default:
return "", fmt.Errorf("unknown field profiles.%s", field)
}
}
func getMultiInstanceField(o *MultiInstanceConfig, field string) (string, error) {
if strings.HasPrefix(field, "restart.") {
return getMultiInstanceRestartField(&o.Restart, strings.TrimPrefix(field, "restart."))
}
switch field {
case "strategy":
return o.Strategy, nil
case "allocationPolicy":
return o.AllocationPolicy, nil
case "instancePortStart":
return formatIntPtr(o.InstancePortStart), nil
case "instancePortEnd":
return formatIntPtr(o.InstancePortEnd), nil
default:
return "", fmt.Errorf("unknown field multiInstance.%s", field)
}
}
func getMultiInstanceRestartField(r *MultiInstanceRestartConfig, field string) (string, error) {
switch field {
case "maxRestarts":
return formatIntPtr(r.MaxRestarts), nil
case "initBackoffSec":
return formatIntPtr(r.InitBackoffSec), nil
case "maxBackoffSec":
return formatIntPtr(r.MaxBackoffSec), nil
case "stableAfterSec":
return formatIntPtr(r.StableAfterSec), nil
default:
return "", fmt.Errorf("unknown field multiInstance.restart.%s", field)
}
}
func getAttachField(a *AttachConfig, field string) (string, error) {
switch field {
case "enabled":
return formatBoolPtr(a.Enabled), nil
case "allowHosts":
return strings.Join(a.AllowHosts, ","), nil
case "allowSchemes":
return strings.Join(a.AllowSchemes, ","), nil
default:
return "", fmt.Errorf("unknown field security.attach.%s", field)
}
}
func setIDPIField(i *IDPIConfig, field, value string) error {
switch field {
case "enabled":
b, err := parseBool(value)
if err != nil {
return fmt.Errorf("security.idpi.enabled: %w", err)
}
i.Enabled = b
case "allowedDomains":
i.AllowedDomains = parseCSVList(value)
case "strictMode":
b, err := parseBool(value)
if err != nil {
return fmt.Errorf("security.idpi.strictMode: %w", err)
}
i.StrictMode = b
case "scanContent":
b, err := parseBool(value)
if err != nil {
return fmt.Errorf("security.idpi.scanContent: %w", err)
}
i.ScanContent = b
case "wrapContent":
b, err := parseBool(value)
if err != nil {
return fmt.Errorf("security.idpi.wrapContent: %w", err)
}
i.WrapContent = b
case "customPatterns":
i.CustomPatterns = parseCSVList(value)
default:
return fmt.Errorf("unknown field security.idpi.%s", field)
}
return nil
}
func getIDPIField(i *IDPIConfig, field string) (string, error) {
switch field {
case "enabled":
return strconv.FormatBool(i.Enabled), nil
case "allowedDomains":
return strings.Join(i.AllowedDomains, ","), nil
case "strictMode":
return strconv.FormatBool(i.StrictMode), nil
case "scanContent":
return strconv.FormatBool(i.ScanContent), nil
case "wrapContent":
return strconv.FormatBool(i.WrapContent), nil
case "customPatterns":
return strings.Join(i.CustomPatterns, ","), nil
default:
return "", fmt.Errorf("unknown field security.idpi.%s", field)
}
}
func getTimeoutsField(t *TimeoutsConfig, field string) (string, error) {
switch field {
case "actionSec":
return strconv.Itoa(t.ActionSec), nil
case "navigateSec":
return strconv.Itoa(t.NavigateSec), nil
case "shutdownSec":
return strconv.Itoa(t.ShutdownSec), nil
case "waitNavMs":
return strconv.Itoa(t.WaitNavMs), nil
default:
return "", fmt.Errorf("unknown field timeouts.%s", field)
}
}
func formatBoolPtr(b *bool) string {
if b == nil {
return ""
}
if *b {
return "true"
}
return "false"
}
func formatIntPtr(n *int) string {
if n == nil {
return ""
}
return strconv.Itoa(*n)
}
func parseBool(s string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "true", "1", "yes", "on":
return true, nil
case "false", "0", "no", "off":
return false, nil
default:
return false, fmt.Errorf("invalid boolean %q (use true/false)", s)
}
}
func parseCSVList(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
raw := strings.Split(s, ",")
out := make([]string, 0, len(raw))
for _, item := range raw {
item = strings.TrimSpace(item)
if item != "" {
out = append(out, item)
}
}
return out
}
// PatchConfigJSON merges a JSON object into FileConfig.
// The patch should be a valid JSON object with the same structure as FileConfig.
func PatchConfigJSON(fc *FileConfig, jsonPatch string) error {
// First, serialize current config to JSON
current, err := json.Marshal(fc)
if err != nil {
return fmt.Errorf("failed to serialize current config: %w", err)
}
// Unmarshal current into a map for merging
var currentMap map[string]interface{}
if err := json.Unmarshal(current, &currentMap); err != nil {
return fmt.Errorf("failed to parse current config: %w", err)
}
// Parse the patch
var patchMap map[string]interface{}
if err := json.Unmarshal([]byte(jsonPatch), &patchMap); err != nil {
return fmt.Errorf("invalid JSON patch: %w", err)
}
// Deep merge patch into current
merged := deepMerge(currentMap, patchMap)
// Serialize merged back to JSON
mergedJSON, err := json.Marshal(merged)
if err != nil {
return fmt.Errorf("failed to serialize merged config: %w", err)
}
// Unmarshal back into FileConfig
if err := json.Unmarshal(mergedJSON, fc); err != nil {
return fmt.Errorf("failed to parse merged config: %w", err)
}
return nil
}
// deepMerge recursively merges src into dst, returning the result.
func deepMerge(dst, src map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{})
// Copy dst
for k, v := range dst {
result[k] = v
}
// Merge src
for k, v := range src {
if srcMap, ok := v.(map[string]interface{}); ok {
if dstMap, ok := result[k].(map[string]interface{}); ok {
result[k] = deepMerge(dstMap, srcMap)
continue
}
}
result[k] = v
}
return result
}
// LoadFileConfig loads a FileConfig from the default or specified path.
// Returns the config and the path it was loaded from.
func LoadFileConfig() (*FileConfig, string, error) {
configPath := envOr("PINCHTAB_CONFIG", filepath.Join(userConfigDir(), "config.json"))
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
// Return empty config if file doesn't exist
return &FileConfig{}, configPath, nil
}
return nil, configPath, fmt.Errorf("failed to read config file: %w", err)
}
var fc *FileConfig
if isLegacyConfig(data) {
var lc legacyFileConfig
if err := json.Unmarshal(data, &lc); err != nil {
return nil, configPath, fmt.Errorf("failed to parse legacy config: %w", err)
}
defaults := DefaultFileConfig()
fc = &defaults
legacy := convertLegacyConfig(&lc)
if legacy.Server.Port != "" {
fc.Server.Port = legacy.Server.Port
}
if legacy.Server.Token != "" {
fc.Server.Token = legacy.Server.Token
}
if legacy.Server.StateDir != "" {
fc.Server.StateDir = legacy.Server.StateDir
}
if legacy.InstanceDefaults.Mode != "" {
fc.InstanceDefaults.Mode = legacy.InstanceDefaults.Mode
}
if legacy.InstanceDefaults.NoRestore != nil {
fc.InstanceDefaults.NoRestore = legacy.InstanceDefaults.NoRestore
}
if legacy.InstanceDefaults.MaxTabs != nil {
fc.InstanceDefaults.MaxTabs = legacy.InstanceDefaults.MaxTabs
}
if legacy.Profiles.BaseDir != "" {
fc.Profiles.BaseDir = legacy.Profiles.BaseDir
}
if legacy.Profiles.DefaultProfile != "" {
fc.Profiles.DefaultProfile = legacy.Profiles.DefaultProfile
}
if legacy.Security.AllowEvaluate != nil {
fc.Security.AllowEvaluate = legacy.Security.AllowEvaluate
}
if legacy.Security.AllowMacro != nil {
fc.Security.AllowMacro = legacy.Security.AllowMacro
}
if legacy.Security.AllowScreencast != nil {
fc.Security.AllowScreencast = legacy.Security.AllowScreencast
}
if legacy.Security.AllowDownload != nil {
fc.Security.AllowDownload = legacy.Security.AllowDownload
}
if legacy.Security.AllowUpload != nil {
fc.Security.AllowUpload = legacy.Security.AllowUpload
}
if legacy.Timeouts.ActionSec != 0 {
fc.Timeouts.ActionSec = legacy.Timeouts.ActionSec
}
if legacy.Timeouts.NavigateSec != 0 {
fc.Timeouts.NavigateSec = legacy.Timeouts.NavigateSec
}
if legacy.MultiInstance.InstancePortStart != nil {
fc.MultiInstance.InstancePortStart = legacy.MultiInstance.InstancePortStart
}
if legacy.MultiInstance.InstancePortEnd != nil {
fc.MultiInstance.InstancePortEnd = legacy.MultiInstance.InstancePortEnd
}
} else {
defaults := DefaultFileConfig()
defaults.ConfigVersion = "" // Don't assume version — let file content determine it
fc = &defaults
if err := json.Unmarshal(data, fc); err != nil {
return nil, configPath, fmt.Errorf("failed to parse config: %w", err)
}
}
return fc, configPath, nil
}
// SaveFileConfig saves a FileConfig to the specified path.
func SaveFileConfig(fc *FileConfig, path string) error {
// Create directory if needed
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
data, err := json.MarshalIndent(fc, "", " ")
if err != nil {
return fmt.Errorf("failed to serialize config: %w", err)
}
// Add trailing newline
data = append(data, '\n')
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}