551 lines
16 KiB
Go
551 lines
16 KiB
Go
// Package storage provides file-based persistence for HMI interface definitions.
|
|
package storage
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
)
|
|
|
|
// 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"`
|
|
// Kind is "plot" for split-layout plot panels, empty for free-form panels.
|
|
Kind string `json:"kind,omitempty"`
|
|
}
|
|
|
|
// VersionMeta describes a single persisted revision of an interface.
|
|
type VersionMeta struct {
|
|
Version int `json:"version"`
|
|
Name string `json:"name"`
|
|
Tag string `json:"tag,omitempty"`
|
|
Current bool `json:"current"`
|
|
SavedAt time.Time `json:"savedAt"`
|
|
}
|
|
|
|
// Store persists interface definitions as XML files under {storageDir}/interfaces/
|
|
// and workspace-level JSON blobs directly in {storageDir}.
|
|
type Store struct {
|
|
rootDir string // {storageDir} — for workspace-level files (groups.json, etc.)
|
|
dir string // {storageDir}/interfaces
|
|
}
|
|
|
|
// 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{rootDir: storageDir, dir: dir}, nil
|
|
}
|
|
|
|
// ReadGroups returns the raw JSON array of group tree nodes.
|
|
// Returns an empty JSON array if no groups have been saved yet.
|
|
func (s *Store) ReadGroups() ([]byte, error) {
|
|
data, err := os.ReadFile(filepath.Join(s.rootDir, "groups.json"))
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return []byte("[]"), nil
|
|
}
|
|
return data, err
|
|
}
|
|
|
|
// WriteGroups persists the group tree. data must be a valid JSON array.
|
|
func (s *Store) WriteGroups(data []byte) error {
|
|
return os.WriteFile(filepath.Join(s.rootDir, "groups.json"), data, 0o644)
|
|
}
|
|
|
|
// validateID returns an error if id could be used for path traversal or is
|
|
// otherwise unsafe. Valid IDs contain only letters, digits, hyphens, and
|
|
// underscores.
|
|
func validateID(id string) error {
|
|
if id == "" {
|
|
return fmt.Errorf("interface ID must not be empty")
|
|
}
|
|
for _, r := range id {
|
|
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' {
|
|
return fmt.Errorf("invalid character %q in interface ID", r)
|
|
}
|
|
}
|
|
return 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 {
|
|
name := e.Name()
|
|
if e.IsDir() || !strings.HasSuffix(strings.ToLower(name), ".xml") {
|
|
continue
|
|
}
|
|
// Skip versioned backups: id.v1.xml, id.v2.xml, etc.
|
|
if isVersioned(name) {
|
|
continue
|
|
}
|
|
|
|
id := strings.TrimSuffix(name, filepath.Ext(name))
|
|
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
|
|
}
|
|
|
|
func isVersioned(name string) bool {
|
|
// Format: <id>.v<number>.xml
|
|
parts := strings.Split(name, ".")
|
|
if len(parts) != 3 {
|
|
return false
|
|
}
|
|
vPart := parts[1]
|
|
if !strings.HasPrefix(vPart, "v") {
|
|
return false
|
|
}
|
|
_, err := strconv.Atoi(vPart[1:])
|
|
return err == 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"`
|
|
Tag string `xml:"tag,attr"`
|
|
Kind string `xml:"kind,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, Kind: root.Kind}, nil
|
|
}
|
|
|
|
// Get returns the raw XML bytes for the interface with the given ID.
|
|
func (s *Store) Get(id string) ([]byte, error) {
|
|
if err := validateID(id); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrNotFound, err)
|
|
}
|
|
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. If tag is non-empty it is stamped as the revision label.
|
|
func (s *Store) Create(xmlData []byte, tag string) (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())
|
|
}
|
|
|
|
// Initialize version to 1 and stamp the generated ID so the persisted XML
|
|
// is self-consistent (the client sends an empty id for new interfaces).
|
|
updatedData, err := setXMLAttribute(xmlData, "version", "1")
|
|
if err != nil {
|
|
return "", fmt.Errorf("initialize version attribute: %w", err)
|
|
}
|
|
updatedData, err = setXMLAttribute(updatedData, "id", id)
|
|
if err != nil {
|
|
return "", fmt.Errorf("stamp id attribute: %w", err)
|
|
}
|
|
if tag != "" {
|
|
updatedData, err = setXMLAttribute(updatedData, "tag", tag)
|
|
if err != nil {
|
|
return "", fmt.Errorf("set tag attribute: %w", err)
|
|
}
|
|
}
|
|
|
|
return id, os.WriteFile(s.filePath(id), updatedData, 0o644)
|
|
}
|
|
|
|
// Update replaces the XML for an existing interface, preserving the previous
|
|
// version as a separate file. If tag is non-empty it is stamped as the label
|
|
// for the new revision.
|
|
func (s *Store) Update(id string, xmlData []byte, tag string) error {
|
|
if err := validateID(id); err != nil {
|
|
return ErrNotFound
|
|
}
|
|
|
|
oldPath := s.filePath(id)
|
|
oldData, err := os.ReadFile(oldPath)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Parse current version to name the backup file.
|
|
var oldRoot rootAttrs
|
|
if err := xml.Unmarshal(oldData, &oldRoot); err != nil {
|
|
return fmt.Errorf("parse existing XML: %w", err)
|
|
}
|
|
if oldRoot.Version < 1 {
|
|
oldRoot.Version = 1
|
|
}
|
|
|
|
// Move old file to backup path: id.vN.xml
|
|
backupPath := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, oldRoot.Version))
|
|
if err := os.WriteFile(backupPath, oldData, 0o644); err != nil {
|
|
return fmt.Errorf("create backup: %w", err)
|
|
}
|
|
|
|
// Update version in new data.
|
|
newVersion := oldRoot.Version + 1
|
|
updatedData, err := setXMLAttribute(xmlData, "version", fmt.Sprintf("%d", newVersion))
|
|
if err != nil {
|
|
return fmt.Errorf("update version attribute: %w", err)
|
|
}
|
|
if tag != "" {
|
|
updatedData, err = setXMLAttribute(updatedData, "tag", tag)
|
|
if err != nil {
|
|
return fmt.Errorf("set tag attribute: %w", err)
|
|
}
|
|
}
|
|
|
|
return os.WriteFile(oldPath, updatedData, 0o644)
|
|
}
|
|
|
|
// Versions returns metadata for every persisted revision of the interface,
|
|
// ordered newest-first. The current file is flagged with Current=true.
|
|
func (s *Store) Versions(id string) ([]VersionMeta, error) {
|
|
if err := validateID(id); err != nil {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
// Current revision.
|
|
curInfo, err := os.Stat(s.filePath(id))
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, ErrNotFound
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
curRoot, err := s.readRoot(s.filePath(id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := []VersionMeta{{
|
|
Version: curRoot.Version,
|
|
Name: curRoot.Name,
|
|
Tag: curRoot.Tag,
|
|
Current: true,
|
|
SavedAt: curInfo.ModTime(),
|
|
}}
|
|
|
|
// Backup revisions: id.vN.xml
|
|
entries, err := os.ReadDir(s.dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
prefix := id + ".v"
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if e.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".xml") {
|
|
continue
|
|
}
|
|
if !isVersioned(name) {
|
|
continue
|
|
}
|
|
p := filepath.Join(s.dir, name)
|
|
info, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
root, err := s.readRoot(p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, VersionMeta{
|
|
Version: root.Version,
|
|
Name: root.Name,
|
|
Tag: root.Tag,
|
|
SavedAt: info.ModTime(),
|
|
})
|
|
}
|
|
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
|
|
return out, nil
|
|
}
|
|
|
|
// GetVersion returns the raw XML bytes for a specific revision of an interface.
|
|
func (s *Store) GetVersion(id string, version int) ([]byte, error) {
|
|
if err := validateID(id); err != nil {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
// The current file may hold the requested version.
|
|
curRoot, err := s.readRoot(s.filePath(id))
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if curRoot.Version == version {
|
|
return os.ReadFile(s.filePath(id))
|
|
}
|
|
|
|
backupPath := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, version))
|
|
data, err := os.ReadFile(backupPath)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return data, err
|
|
}
|
|
|
|
// versionPath returns the on-disk path for a specific revision, resolving the
|
|
// current file when version matches the live revision.
|
|
func (s *Store) versionPath(id string, version int) (string, error) {
|
|
cur := s.filePath(id)
|
|
curRoot, err := s.readRoot(cur)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return "", ErrNotFound
|
|
}
|
|
return "", err
|
|
}
|
|
if curRoot.Version == version {
|
|
return cur, nil
|
|
}
|
|
backup := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, version))
|
|
if _, err := os.Stat(backup); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return "", ErrNotFound
|
|
}
|
|
return "", err
|
|
}
|
|
return backup, nil
|
|
}
|
|
|
|
// SetVersionTag sets (or clears, when tag is empty) the label of a specific
|
|
// revision in place, without creating a new revision.
|
|
func (s *Store) SetVersionTag(id string, version int, tag string) error {
|
|
if err := validateID(id); err != nil {
|
|
return ErrNotFound
|
|
}
|
|
path, err := s.versionPath(id, version)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
updated, err := setXMLAttribute(data, "tag", tag)
|
|
if err != nil {
|
|
return fmt.Errorf("set tag attribute: %w", err)
|
|
}
|
|
return os.WriteFile(path, updated, 0o644)
|
|
}
|
|
|
|
// Promote makes a past revision the current one by re-saving its content as a
|
|
// new revision on top of history. The existing current revision is preserved
|
|
// as a backup, so promotion is non-destructive.
|
|
func (s *Store) Promote(id string, version int) error {
|
|
data, err := s.GetVersion(id, version)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.Update(id, data, fmt.Sprintf("restored from v%d", version))
|
|
}
|
|
|
|
// Fork creates a brand-new interface from the content of a specific revision,
|
|
// assigning a fresh unique ID and resetting its version to 1.
|
|
func (s *Store) Fork(id string, version int) (string, error) {
|
|
data, err := s.GetVersion(id, version)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
newID := id + "-fork-" + fmt.Sprintf("%d", time.Now().UnixMilli())
|
|
updated, err := setXMLAttribute(data, "version", "1")
|
|
if err != nil {
|
|
return "", fmt.Errorf("reset version attribute: %w", err)
|
|
}
|
|
updated, err = setXMLAttribute(updated, "id", newID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("stamp id attribute: %w", err)
|
|
}
|
|
updated, err = setXMLAttribute(updated, "tag", "")
|
|
if err != nil {
|
|
return "", fmt.Errorf("clear tag attribute: %w", err)
|
|
}
|
|
return newID, os.WriteFile(s.filePath(newID), updated, 0o644)
|
|
}
|
|
|
|
// readRoot parses the top-level interface attributes from the file at path.
|
|
func (s *Store) readRoot(path string) (rootAttrs, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return rootAttrs{}, err
|
|
}
|
|
var root rootAttrs
|
|
if err := xml.Unmarshal(data, &root); err != nil {
|
|
return rootAttrs{}, err
|
|
}
|
|
return root, nil
|
|
}
|
|
|
|
// setXMLAttribute is a primitive helper to update a top-level attribute in an
|
|
// XML blob without full unmarshal/marshal (which might mess up formatting).
|
|
func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
|
|
// Very basic implementation: look for the attribute in the first 512 bytes.
|
|
// A proper implementation would use an XML encoder or a better regex.
|
|
s := string(data)
|
|
|
|
// Locate the root element's opening tag, skipping any XML declaration
|
|
// (<?xml ... ?>), comments, or processing instructions. Operating on the
|
|
// first '>' in the document would match the '?>' of the XML declaration and
|
|
// corrupt its version attribute (producing invalid XML).
|
|
tagStart := -1
|
|
for i := 0; i+1 < len(s); i++ {
|
|
if s[i] == '<' && s[i+1] != '?' && s[i+1] != '!' {
|
|
tagStart = i
|
|
break
|
|
}
|
|
}
|
|
if tagStart == -1 {
|
|
return nil, errors.New("malformed XML: no root element")
|
|
}
|
|
rel := strings.Index(s[tagStart:], ">")
|
|
if rel == -1 {
|
|
return nil, errors.New("malformed XML: no root tag end")
|
|
}
|
|
tagEnd := tagStart + rel
|
|
rootTag := s[tagStart:tagEnd]
|
|
|
|
attrSearch := " " + attr + "=\""
|
|
idx := strings.Index(rootTag, attrSearch)
|
|
if idx == -1 {
|
|
// Attribute not found, insert it before the tag ends.
|
|
insertIdx := tagEnd
|
|
if s[tagEnd-1] == '/' {
|
|
insertIdx-- // handle <tag />
|
|
}
|
|
return []byte(s[:insertIdx] + fmt.Sprintf(" %s=\"%s\"", attr, value) + s[insertIdx:]), nil
|
|
}
|
|
|
|
// Attribute found, replace its value (idx is relative to rootTag).
|
|
valStart := tagStart + idx + len(attrSearch)
|
|
valEnd := strings.Index(s[valStart:], "\"")
|
|
if valEnd == -1 {
|
|
return nil, errors.New("malformed XML: attribute value not closed")
|
|
}
|
|
return []byte(s[:valStart] + value + s[valStart+valEnd:]), nil
|
|
}
|
|
|
|
// Delete moves the interface with the given ID — and all of its version
|
|
// backups — into a timestamped trash folder so it can be recovered if needed.
|
|
// Nothing is destroyed; recovery is a matter of moving the files back.
|
|
func (s *Store) Delete(id string) error {
|
|
if err := validateID(id); err != nil {
|
|
return ErrNotFound
|
|
}
|
|
if _, err := os.Stat(s.filePath(id)); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
trashDir := filepath.Join(s.rootDir, "trash", "interfaces", fmt.Sprintf("%s.%d", id, time.Now().UnixMilli()))
|
|
if err := os.MkdirAll(trashDir, 0o755); err != nil {
|
|
return fmt.Errorf("create trash dir: %w", err)
|
|
}
|
|
|
|
// Move the current file plus every versioned backup (id.vN.xml), preserving
|
|
// their original names so a restore is a straight move-back.
|
|
entries, err := os.ReadDir(s.dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
if name == id+".xml" || (strings.HasPrefix(name, id+".v") && isVersioned(name)) {
|
|
if err := os.Rename(filepath.Join(s.dir, name), filepath.Join(trashDir, name)); err != nil {
|
|
return fmt.Errorf("move %s to trash: %w", name, err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Clone duplicates an interface, assigning a new unique ID.
|
|
func (s *Store) Clone(srcID string) (string, error) {
|
|
if err := validateID(srcID); err != nil {
|
|
return "", ErrNotFound
|
|
}
|
|
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
|
|
}
|