feat: complete Tasks 1-3 (setup, models+storage, config management)
- Task 1: Project setup (go.mod, Makefile, .gitignore, main.go) - Task 2: Core data models (Host, KeyPair, Snippet, AppConfig) + JSON storage layer - Task 3: Configuration management with cross-platform path support (macOS/Linux/Windows) - Updated PROJECT_STATE.md with progress tracking
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||
)
|
||||
|
||||
// Config manages application configuration and paths
|
||||
type Config struct {
|
||||
appName string
|
||||
configDir string
|
||||
dataDir string
|
||||
appConfig *models.AppConfig
|
||||
}
|
||||
|
||||
// New creates a new Config instance
|
||||
func New() (*Config, error) {
|
||||
appName := "hostkeeper"
|
||||
|
||||
configDir, err := getConfigDir(appName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get config directory: %w", err)
|
||||
}
|
||||
|
||||
dataDir := filepath.Join(configDir, "data")
|
||||
|
||||
c := &Config{
|
||||
appName: appName,
|
||||
configDir: configDir,
|
||||
dataDir: dataDir,
|
||||
}
|
||||
|
||||
// Create directories
|
||||
if err := os.MkdirAll(configDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(dataDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
// Load or create config
|
||||
if err := c.load(); err != nil {
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// getConfigDir returns the OS-appropriate config directory for the app
|
||||
func getConfigDir(appName string) (string, error) {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, "Library", "Application Support", appName), nil
|
||||
case "linux":
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, appName), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".config", appName), nil
|
||||
case "windows":
|
||||
appData := os.Getenv("APPDATA")
|
||||
if appData != "" {
|
||||
return filepath.Join(appData, appName), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, "AppData", "Roaming", appName), nil
|
||||
default:
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, "."+appName), nil
|
||||
}
|
||||
}
|
||||
|
||||
// load reads the config file, or creates a default one if it doesn't exist
|
||||
func (c *Config) load() error {
|
||||
configPath := c.GetConfigFilePath()
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Create default config
|
||||
c.appConfig = models.DefaultConfig()
|
||||
return c.Save()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
c.appConfig = models.DefaultConfig()
|
||||
if err := json.Unmarshal(data, c.appConfig); err != nil {
|
||||
return fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save writes the current configuration to disk
|
||||
func (c *Config) Save() error {
|
||||
configPath := c.GetConfigFilePath()
|
||||
|
||||
data, err := json.MarshalIndent(c.appConfig, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(configPath, data, 0600)
|
||||
}
|
||||
|
||||
// GetAppConfig returns the application configuration
|
||||
func (c *Config) GetAppConfig() *models.AppConfig {
|
||||
return c.appConfig
|
||||
}
|
||||
|
||||
// UpdateAppConfig updates the application configuration
|
||||
func (c *Config) UpdateAppConfig(cfg *models.AppConfig) error {
|
||||
c.appConfig = cfg
|
||||
return c.Save()
|
||||
}
|
||||
|
||||
// GetConfigDir returns the configuration directory path
|
||||
func (c *Config) GetConfigDir() string {
|
||||
return c.configDir
|
||||
}
|
||||
|
||||
// GetDataDir returns the data directory path
|
||||
func (c *Config) GetDataDir() string {
|
||||
return c.dataDir
|
||||
}
|
||||
|
||||
// GetConfigFilePath returns the full path to the config JSON file
|
||||
func (c *Config) GetConfigFilePath() string {
|
||||
return filepath.Join(c.configDir, "config.json")
|
||||
}
|
||||
|
||||
// GetHostsFilePath returns the full path to the hosts JSON file
|
||||
func (c *Config) GetHostsFilePath() string {
|
||||
return filepath.Join(c.dataDir, "hosts.json")
|
||||
}
|
||||
|
||||
// GetKeysFilePath returns the full path to the keys JSON file
|
||||
func (c *Config) GetKeysFilePath() string {
|
||||
return filepath.Join(c.dataDir, "keys.json")
|
||||
}
|
||||
|
||||
// GetSnippetsFilePath returns the full path to the snippets JSON file
|
||||
func (c *Config) GetSnippetsFilePath() string {
|
||||
return filepath.Join(c.dataDir, "snippets.json")
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||
)
|
||||
|
||||
// JSONStorage implements Storage interface using JSON files
|
||||
type JSONStorage struct {
|
||||
dataDir string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewJSONStorage creates a new JSON storage instance
|
||||
func NewJSONStorage(dataDir string) (*JSONStorage, error) {
|
||||
if err := os.MkdirAll(dataDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
s := &JSONStorage{dataDir: dataDir}
|
||||
|
||||
if err := s.ensureDataFiles(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize data files: %w", err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *JSONStorage) ensureDataFiles() error {
|
||||
files := map[string]string{
|
||||
"hosts.json": "hosts",
|
||||
"keys.json": "key_pairs",
|
||||
"snippets.json": "snippets",
|
||||
}
|
||||
|
||||
for file, key := range files {
|
||||
path := filepath.Join(s.dataDir, file)
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
data := map[string]interface{}{key: []interface{}{}}
|
||||
dataBytes, _ := json.MarshalIndent(data, "", " ")
|
||||
if err := os.WriteFile(path, dataBytes, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============ Host Operations ============
|
||||
|
||||
func (s *JSONStorage) getHostsPath() string {
|
||||
return filepath.Join(s.dataDir, "hosts.json")
|
||||
}
|
||||
|
||||
func (s *JSONStorage) ListHosts(ctx context.Context) ([]*models.Host, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var data struct {
|
||||
Hosts []*models.Host `json:"hosts"`
|
||||
}
|
||||
|
||||
if err := s.readJSON(s.getHostsPath(), &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to read hosts: %w", err)
|
||||
}
|
||||
|
||||
return data.Hosts, nil
|
||||
}
|
||||
|
||||
func (s *JSONStorage) GetHost(ctx context.Context, id string) (*models.Host, error) {
|
||||
hosts, err := s.ListHosts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
if host.ID == id {
|
||||
return host, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("host not found: %s", id)
|
||||
}
|
||||
|
||||
func (s *JSONStorage) SaveHost(ctx context.Context, host *models.Host) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
hosts, err := s.ListHosts(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, h := range hosts {
|
||||
if h.ID == host.ID {
|
||||
hosts[i] = host
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
|
||||
return s.replaceHosts(hosts)
|
||||
}
|
||||
|
||||
func (s *JSONStorage) DeleteHost(ctx context.Context, id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
hosts, err := s.ListHosts(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, host := range hosts {
|
||||
if host.ID == id {
|
||||
hosts = append(hosts[:i], hosts[i+1:]...)
|
||||
return s.replaceHosts(hosts)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("host not found: %s", id)
|
||||
}
|
||||
|
||||
func (s *JSONStorage) replaceHosts(hosts []*models.Host) error {
|
||||
var data struct {
|
||||
Hosts []*models.Host `json:"hosts"`
|
||||
}
|
||||
data.Hosts = hosts
|
||||
|
||||
bytes, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal hosts: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(s.getHostsPath(), bytes, 0600)
|
||||
}
|
||||
|
||||
// ============ KeyPair Operations ============
|
||||
|
||||
func (s *JSONStorage) getKeysPath() string {
|
||||
return filepath.Join(s.dataDir, "keys.json")
|
||||
}
|
||||
|
||||
func (s *JSONStorage) ListKeyPairs(ctx context.Context) ([]*models.KeyPair, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var data struct {
|
||||
KeyPairs []*models.KeyPair `json:"key_pairs"`
|
||||
}
|
||||
|
||||
if err := s.readJSON(s.getKeysPath(), &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to read key pairs: %w", err)
|
||||
}
|
||||
|
||||
return data.KeyPairs, nil
|
||||
}
|
||||
|
||||
func (s *JSONStorage) GetKeyPair(ctx context.Context, id string) (*models.KeyPair, error) {
|
||||
keys, err := s.ListKeyPairs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if key.ID == id {
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("key pair not found: %s", id)
|
||||
}
|
||||
|
||||
func (s *JSONStorage) SaveKeyPair(ctx context.Context, keyPair *models.KeyPair) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
keys, err := s.ListKeyPairs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, k := range keys {
|
||||
if k.ID == keyPair.ID {
|
||||
keys[i] = keyPair
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
keys = append(keys, keyPair)
|
||||
}
|
||||
|
||||
return s.replaceKeyPairs(keys)
|
||||
}
|
||||
|
||||
func (s *JSONStorage) DeleteKeyPair(ctx context.Context, id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
keys, err := s.ListKeyPairs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, key := range keys {
|
||||
if key.ID == id {
|
||||
keys = append(keys[:i], keys[i+1:]...)
|
||||
return s.replaceKeyPairs(keys)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("key pair not found: %s", id)
|
||||
}
|
||||
|
||||
func (s *JSONStorage) replaceKeyPairs(keys []*models.KeyPair) error {
|
||||
var data struct {
|
||||
KeyPairs []*models.KeyPair `json:"key_pairs"`
|
||||
}
|
||||
data.KeyPairs = keys
|
||||
|
||||
bytes, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal key pairs: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(s.getKeysPath(), bytes, 0600)
|
||||
}
|
||||
|
||||
// ============ Snippet Operations ============
|
||||
|
||||
func (s *JSONStorage) getSnippetsPath() string {
|
||||
return filepath.Join(s.dataDir, "snippets.json")
|
||||
}
|
||||
|
||||
func (s *JSONStorage) ListSnippets(ctx context.Context) ([]*models.Snippet, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var data struct {
|
||||
Snippets []*models.Snippet `json:"snippets"`
|
||||
}
|
||||
|
||||
if err := s.readJSON(s.getSnippetsPath(), &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to read snippets: %w", err)
|
||||
}
|
||||
|
||||
return data.Snippets, nil
|
||||
}
|
||||
|
||||
func (s *JSONStorage) GetSnippet(ctx context.Context, id string) (*models.Snippet, error) {
|
||||
snippets, err := s.ListSnippets(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, snippet := range snippets {
|
||||
if snippet.ID == id {
|
||||
return snippet, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("snippet not found: %s", id)
|
||||
}
|
||||
|
||||
func (s *JSONStorage) SaveSnippet(ctx context.Context, snippet *models.Snippet) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
snippets, err := s.ListSnippets(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, sn := range snippets {
|
||||
if sn.ID == snippet.ID {
|
||||
snippets[i] = snippet
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
snippets = append(snippets, snippet)
|
||||
}
|
||||
|
||||
return s.replaceSnippets(snippets)
|
||||
}
|
||||
|
||||
func (s *JSONStorage) DeleteSnippet(ctx context.Context, id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
snippets, err := s.ListSnippets(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, snippet := range snippets {
|
||||
if snippet.ID == id {
|
||||
snippets = append(snippets[:i], snippets[i+1:]...)
|
||||
return s.replaceSnippets(snippets)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("snippet not found: %s", id)
|
||||
}
|
||||
|
||||
func (s *JSONStorage) replaceSnippets(snippets []*models.Snippet) error {
|
||||
var data struct {
|
||||
Snippets []*models.Snippet `json:"snippets"`
|
||||
}
|
||||
data.Snippets = snippets
|
||||
|
||||
bytes, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal snippets: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(s.getSnippetsPath(), bytes, 0600)
|
||||
}
|
||||
|
||||
// ============ Export/Import ============
|
||||
|
||||
func (s *JSONStorage) ExportData(ctx context.Context) (*ExportData, error) {
|
||||
hosts, err := s.ListHosts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keys, err := s.ListKeyPairs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snippets, err := s.ListSnippets(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ExportData{
|
||||
Hosts: hosts,
|
||||
KeyPairs: keys,
|
||||
Snippets: snippets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *JSONStorage) ImportData(ctx context.Context, data *ExportData, strategy MergeStrategy) error {
|
||||
switch strategy {
|
||||
case MergeStrategyReplace:
|
||||
if err := s.replaceHosts(data.Hosts); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.replaceKeyPairs(data.KeyPairs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.replaceSnippets(data.Snippets); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case MergeStrategyMerge:
|
||||
if err := s.mergeHosts(ctx, data.Hosts); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.mergeKeyPairs(ctx, data.KeyPairs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.mergeSnippets(ctx, data.Snippets); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown merge strategy: %s", strategy)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *JSONStorage) mergeHosts(ctx context.Context, newHosts []*models.Host) error {
|
||||
existingHosts, err := s.ListHosts(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingIDs := make(map[string]bool)
|
||||
for _, host := range existingHosts {
|
||||
existingIDs[host.ID] = true
|
||||
}
|
||||
|
||||
for _, newHost := range newHosts {
|
||||
if !existingIDs[newHost.ID] {
|
||||
existingHosts = append(existingHosts, newHost)
|
||||
}
|
||||
}
|
||||
|
||||
return s.replaceHosts(existingHosts)
|
||||
}
|
||||
|
||||
func (s *JSONStorage) mergeKeyPairs(ctx context.Context, newKeys []*models.KeyPair) error {
|
||||
existingKeys, err := s.ListKeyPairs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingIDs := make(map[string]bool)
|
||||
for _, key := range existingKeys {
|
||||
existingIDs[key.ID] = true
|
||||
}
|
||||
|
||||
for _, newKey := range newKeys {
|
||||
if !existingIDs[newKey.ID] {
|
||||
existingKeys = append(existingKeys, newKey)
|
||||
}
|
||||
}
|
||||
|
||||
return s.replaceKeyPairs(existingKeys)
|
||||
}
|
||||
|
||||
func (s *JSONStorage) mergeSnippets(ctx context.Context, newSnippets []*models.Snippet) error {
|
||||
existingSnippets, err := s.ListSnippets(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingIDs := make(map[string]bool)
|
||||
for _, snippet := range existingSnippets {
|
||||
existingIDs[snippet.ID] = true
|
||||
}
|
||||
|
||||
for _, newSnippet := range newSnippets {
|
||||
if !existingIDs[newSnippet.ID] {
|
||||
existingSnippets = append(existingSnippets, newSnippet)
|
||||
}
|
||||
}
|
||||
|
||||
return s.replaceSnippets(existingSnippets)
|
||||
}
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
func (s *JSONStorage) readJSON(path string, v interface{}) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||
)
|
||||
|
||||
// Storage defines the interface for data persistence
|
||||
type Storage interface {
|
||||
// Host operations
|
||||
ListHosts(ctx context.Context) ([]*models.Host, error)
|
||||
GetHost(ctx context.Context, id string) (*models.Host, error)
|
||||
SaveHost(ctx context.Context, host *models.Host) error
|
||||
DeleteHost(ctx context.Context, id string) error
|
||||
|
||||
// KeyPair operations
|
||||
ListKeyPairs(ctx context.Context) ([]*models.KeyPair, error)
|
||||
GetKeyPair(ctx context.Context, id string) (*models.KeyPair, error)
|
||||
SaveKeyPair(ctx context.Context, keyPair *models.KeyPair) error
|
||||
DeleteKeyPair(ctx context.Context, id string) error
|
||||
|
||||
// Snippet operations
|
||||
ListSnippets(ctx context.Context) ([]*models.Snippet, error)
|
||||
GetSnippet(ctx context.Context, id string) (*models.Snippet, error)
|
||||
SaveSnippet(ctx context.Context, snippet *models.Snippet) error
|
||||
DeleteSnippet(ctx context.Context, id string) error
|
||||
|
||||
// Export/Import
|
||||
ExportData(ctx context.Context) (*ExportData, error)
|
||||
ImportData(ctx context.Context, data *ExportData, strategy MergeStrategy) error
|
||||
}
|
||||
|
||||
// ExportData represents the data structure for export/import
|
||||
type ExportData struct {
|
||||
Hosts []*models.Host `json:"hosts"`
|
||||
KeyPairs []*models.KeyPair `json:"key_pairs"`
|
||||
Snippets []*models.Snippet `json:"snippets"`
|
||||
}
|
||||
|
||||
// MergeStrategy defines how imported data is merged with existing data
|
||||
type MergeStrategy string
|
||||
|
||||
const (
|
||||
// MergeStrategyReplace replaces all existing data with imported data
|
||||
MergeStrategyReplace MergeStrategy = "replace"
|
||||
// MergeStrategyMerge keeps existing data and adds only new items
|
||||
MergeStrategyMerge MergeStrategy = "merge"
|
||||
)
|
||||
Reference in New Issue
Block a user