Files
uopi/internal/storage/store.go
T
Martino Ferrari 986f6cd6d8 Phase 6
2026-04-25 22:56:09 +02:00

161 lines
4.1 KiB
Go

// Package storage provides file-based persistence for HMI interface definitions.
package storage
import (
"encoding/xml"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// ErrNotFound is returned when the requested interface does not exist.
var ErrNotFound = errors.New("interface not found")
// InterfaceMeta is the lightweight representation returned by List.
type InterfaceMeta struct {
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
}
// Store persists interface definitions as XML files under {storageDir}/interfaces/.
type Store struct {
dir string
}
// New opens (and creates, if needed) the storage directory.
func New(storageDir string) (*Store, error) {
dir := filepath.Join(storageDir, "interfaces")
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create interfaces dir: %w", err)
}
return &Store{dir: dir}, nil
}
func (s *Store) filePath(id string) string {
return filepath.Join(s.dir, id+".xml")
}
// List returns metadata for every stored interface.
func (s *Store) List() ([]InterfaceMeta, error) {
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil, err
}
var out []InterfaceMeta
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".xml") {
continue
}
id := strings.TrimSuffix(e.Name(), ".xml")
meta, err := s.readMeta(id)
if err != nil {
continue // skip corrupt files silently
}
out = append(out, meta)
}
if out == nil {
out = []InterfaceMeta{}
}
return out, nil
}
// rootAttrs is used to parse only the top-level XML attributes.
type rootAttrs struct {
XMLName xml.Name `xml:"interface"`
ID string `xml:"id,attr"`
Name string `xml:"name,attr"`
Version int `xml:"version,attr"`
}
func (s *Store) readMeta(id string) (InterfaceMeta, error) {
data, err := os.ReadFile(s.filePath(id))
if err != nil {
return InterfaceMeta{}, err
}
var root rootAttrs
if err := xml.Unmarshal(data, &root); err != nil {
return InterfaceMeta{}, err
}
return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version}, nil
}
// Get returns the raw XML bytes for the interface with the given ID.
func (s *Store) Get(id string) ([]byte, error) {
data, err := os.ReadFile(s.filePath(id))
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
}
return data, err
}
// Create stores new interface XML and returns the generated ID.
// The ID is derived from the name attribute; a timestamp suffix is added to
// ensure uniqueness.
func (s *Store) Create(xmlData []byte) (string, error) {
var root rootAttrs
if err := xml.Unmarshal(xmlData, &root); err != nil {
return "", fmt.Errorf("invalid XML: %w", err)
}
id := root.ID
if id == "" {
id = slugify(root.Name)
}
// Guarantee uniqueness.
if _, err := os.Stat(s.filePath(id)); err == nil {
id = id + "-" + fmt.Sprintf("%d", time.Now().UnixMilli())
}
return id, os.WriteFile(s.filePath(id), xmlData, 0o644)
}
// Update replaces the XML for an existing interface.
func (s *Store) Update(id string, xmlData []byte) error {
if _, err := os.Stat(s.filePath(id)); errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
return os.WriteFile(s.filePath(id), xmlData, 0o644)
}
// Delete removes the interface with the given ID.
func (s *Store) Delete(id string) error {
err := os.Remove(s.filePath(id))
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
return err
}
// Clone duplicates an interface, assigning a new unique ID.
func (s *Store) Clone(srcID string) (string, error) {
data, err := s.Get(srcID)
if err != nil {
return "", err
}
newID := srcID + "-copy-" + fmt.Sprintf("%d", time.Now().UnixMilli())
return newID, os.WriteFile(s.filePath(newID), data, 0o644)
}
// slugify converts a human-readable name into a URL-safe identifier.
func slugify(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
default:
if b.Len() > 0 && b.String()[b.Len()-1] != '-' {
b.WriteByte('-')
}
}
}
result := strings.Trim(b.String(), "-")
if result == "" {
return "interface"
}
return result
}