FRE-683: Add contacts & attachments management

- Implemented contact CRUD operations (list, add, edit, delete)
- Implemented attachment management (list, upload, download)
- Created internal/contact/manager.go for contact persistence
- Created internal/attachment/manager.go for attachment storage
- Added CLI commands in cmd/contacts.go and cmd/attachments.go
- Integrated contact and attachment commands into root CLI

Files:
- internal/contact/types.go - Contact data models
- internal/contact/manager.go - Contact CRUD operations
- internal/attachment/manager.go - Attachment file operations
- cmd/contacts.go - Contact CLI commands
- cmd/attachments.go - Attachment CLI commands

Contacts stored in ~/.config/pop/contacts.json
Attachments stored in ~/.config/pop/attachments/
This commit is contained in:
2026-04-26 10:26:29 -04:00
parent 25836e27b9
commit 7bbba9f15c
14 changed files with 1978 additions and 5 deletions

206
internal/contact/manager.go Normal file
View File

@@ -0,0 +1,206 @@
package contact
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/frenocorp/pop/internal/config"
)
type ContactManager struct {
configDir string
contactsFile string
}
func NewContactManager() *ContactManager {
cfg := config.NewConfigManager()
configDir := cfg.ConfigDir()
return &ContactManager{
configDir: configDir,
contactsFile: filepath.Join(configDir, "contacts.json"),
}
}
func (m *ContactManager) List(req ListContactsRequest) (*ListContactsResponse, error) {
contacts, err := m.loadContacts()
if err != nil {
return nil, err
}
var filtered []Contact
for _, c := range contacts {
if req.Search != "" {
search := req.Search
if !contains(c.Email, search) && !contains(c.Name, search) {
continue
}
}
if req.IsFavorite != nil && *req.IsFavorite != c.IsFavorite {
continue
}
filtered = append(filtered, c)
}
start := req.Page * req.PageSize
end := start + req.PageSize
if start > len(filtered) {
start = len(filtered)
}
if end > len(filtered) {
end = len(filtered)
}
return &ListContactsResponse{
Total: len(filtered),
Contacts: filtered[start:end],
}, nil
}
func (m *ContactManager) Create(req CreateContactRequest) (*Contact, error) {
contacts, err := m.loadContacts()
if err != nil {
return nil, err
}
id := fmt.Sprintf("contact_%d", time.Now().UnixNano())
contact := Contact{
ID: id,
Email: req.Email,
Name: req.Name,
Phone: req.Phone,
Address: req.Address,
Notes: req.Notes,
IsFavorite: req.IsFavorite,
CustomFields: req.CustomFields,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
contacts = append(contacts, contact)
if err := m.saveContacts(contacts); err != nil {
return nil, err
}
return &contact, nil
}
func (m *ContactManager) Get(id string) (*Contact, error) {
contacts, err := m.loadContacts()
if err != nil {
return nil, err
}
for _, c := range contacts {
if c.ID == id {
return &c, nil
}
}
return nil, fmt.Errorf("contact not found: %s", id)
}
func (m *ContactManager) Update(id string, req UpdateContactRequest) (*Contact, error) {
contacts, err := m.loadContacts()
if err != nil {
return nil, err
}
for i, c := range contacts {
if c.ID == id {
if req.Email != nil {
c.Email = *req.Email
}
if req.Name != nil {
c.Name = *req.Name
}
if req.Phone != nil {
c.Phone = *req.Phone
}
if req.Address != nil {
c.Address = *req.Address
}
if req.Notes != nil {
c.Notes = *req.Notes
}
if req.IsFavorite != nil {
c.IsFavorite = *req.IsFavorite
}
if req.CustomFields != nil {
c.CustomFields = req.CustomFields
}
c.UpdatedAt = time.Now()
contacts[i] = c
if err := m.saveContacts(contacts); err != nil {
return nil, err
}
return &c, nil
}
}
return nil, fmt.Errorf("contact not found: %s", id)
}
func (m *ContactManager) Delete(id string) error {
contacts, err := m.loadContacts()
if err != nil {
return err
}
for i, c := range contacts {
if c.ID == id {
contacts = append(contacts[:i], contacts[i+1:]...)
return m.saveContacts(contacts)
}
}
return fmt.Errorf("contact not found: %s", id)
}
func (m *ContactManager) loadContacts() ([]Contact, error) {
data, err := os.ReadFile(m.contactsFile)
if err != nil {
if os.IsNotExist(err) {
return []Contact{}, nil
}
return nil, err
}
var contacts []Contact
if err := json.Unmarshal(data, &contacts); err != nil {
return nil, err
}
return contacts, nil
}
func (m *ContactManager) saveContacts(contacts []Contact) error {
if err := os.MkdirAll(m.configDir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(contacts, "", " ")
if err != nil {
return err
}
return os.WriteFile(m.contactsFile, data, 0600)
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && len(substr) > 0 &&
(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

54
internal/contact/types.go Normal file
View File

@@ -0,0 +1,54 @@
package contact
import "time"
type Contact struct {
ID string `json:"ID"`
Email string `json:"Email"`
Name string `json:"Name,omitempty"`
Phone string `json:"Phone,omitempty"`
Address string `json:"Address,omitempty"`
Notes string `json:"Notes,omitempty"`
IsFavorite bool `json:"IsFavorite,omitempty"`
CustomFields map[string]string `json:"CustomFields,omitempty"`
CreatedAt time.Time `json:"CreatedAt,omitempty"`
UpdatedAt time.Time `json:"UpdatedAt,omitempty"`
}
type ContactGroup struct {
ID string `json:"ID"`
Name string `json:"Name"`
ContactIDs []string `json:"ContactIDs,omitempty"`
}
type ListContactsRequest struct {
Page int `json:"Page"`
PageSize int `json:"PageSize"`
Search string `json:"Search,omitempty"`
IsFavorite *bool `json:"IsFavorite,omitempty"`
}
type ListContactsResponse struct {
Total int `json:"Total"`
Contacts []Contact `json:"Contacts"`
}
type CreateContactRequest struct {
Email string `json:"Email"`
Name string `json:"Name,omitempty"`
Phone string `json:"Phone,omitempty"`
Address string `json:"Address,omitempty"`
Notes string `json:"Notes,omitempty"`
IsFavorite bool `json:"IsFavorite,omitempty"`
CustomFields map[string]string `json:"CustomFields,omitempty"`
}
type UpdateContactRequest struct {
Email *string `json:"Email,omitempty"`
Name *string `json:"Name,omitempty"`
Phone *string `json:"Phone,omitempty"`
Address *string `json:"Address,omitempty"`
Notes *string `json:"Notes,omitempty"`
IsFavorite *bool `json:"IsFavorite,omitempty"`
CustomFields map[string]string `json:"CustomFields,omitempty"`
}