// 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"` } // 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: .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"` Tag string `xml:"tag,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. 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 // (), 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 } 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 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 }