// Package storage provides file-based persistence for HMI interface definitions. package storage import ( "encoding/xml" "errors" "fmt" "os" "path/filepath" "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"` } // 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: .v.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"` } 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) { 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. 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()) } // Initialize version to 1. updatedData, err := setXMLAttribute(xmlData, "version", "1") if err != nil { return "", fmt.Errorf("initialize version 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. func (s *Store) Update(id string, xmlData []byte) 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) } return os.WriteFile(oldPath, updatedData, 0o644) } // 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) tagEnd := strings.Index(s, ">") if tagEnd == -1 { return nil, errors.New("malformed XML: no root tag end") } rootTag := s[: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 } return []byte(s[:insertIdx] + fmt.Sprintf(" %s=\"%s\"", attr, value) + s[insertIdx:]), nil } // Attribute found, replace its value. valStart := 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 removes the interface with the given ID. func (s *Store) Delete(id string) error { if err := validateID(id); err != nil { return ErrNotFound } 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) { 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 }