Phase 6
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user