161 lines
4.1 KiB
Go
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
|
|
}
|