Major changes: logic add to panel, local variables, panel histor, users management...

This commit is contained in:
Martino Ferrari
2026-06-18 17:37:04 +02:00
parent 71430bc3b0
commit aba394b84d
54 changed files with 6104 additions and 1166 deletions
+235 -10
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -23,6 +24,15 @@ type InterfaceMeta struct {
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 {
@@ -123,6 +133,7 @@ type rootAttrs struct {
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) {
@@ -151,8 +162,8 @@ func (s *Store) Get(id string) ([]byte, error) {
// 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) {
// 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)
@@ -167,18 +178,30 @@ func (s *Store) Create(xmlData []byte) (string, error) {
id = id + "-" + fmt.Sprintf("%d", time.Now().UnixMilli())
}
// Initialize version to 1.
// 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.
func (s *Store) Update(id string, xmlData []byte) error {
// 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
}
@@ -213,21 +236,223 @@ func (s *Store) Update(id string, xmlData []byte) error {
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)
tagEnd := strings.Index(s, ">")
if tagEnd == -1 {
// 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")
}
rootTag := s[:tagEnd]
tagEnd := tagStart + rel
rootTag := s[tagStart:tagEnd]
attrSearch := " " + attr + "=\""
idx := strings.Index(rootTag, attrSearch)
@@ -240,8 +465,8 @@ func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
return []byte(s[:insertIdx] + fmt.Sprintf(" %s=\"%s\"", attr, value) + s[insertIdx:]), nil
}
// Attribute found, replace its value.
valStart := idx + len(attrSearch)
// 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")
+11 -11
View File
@@ -35,7 +35,7 @@ func TestListEmpty(t *testing.T) {
func TestListAfterCreate(t *testing.T) {
s := newStore(t)
if _, err := s.Create([]byte(sampleXML)); err != nil {
if _, err := s.Create([]byte(sampleXML), ""); err != nil {
t.Fatalf("Create: %v", err)
}
list, err := s.List()
@@ -59,7 +59,7 @@ func TestListAfterCreate(t *testing.T) {
func TestCreateAndGet(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(sampleXML))
id, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
@@ -78,7 +78,7 @@ func TestCreateAndGet(t *testing.T) {
func TestCreateUsesEmbeddedID(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(sampleXML))
id, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
@@ -90,11 +90,11 @@ func TestCreateUsesEmbeddedID(t *testing.T) {
func TestCreateDuplicateGetsUniqueID(t *testing.T) {
s := newStore(t)
id1, err := s.Create([]byte(sampleXML))
id1, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("first Create: %v", err)
}
id2, err := s.Create([]byte(sampleXML))
id2, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("second Create: %v", err)
}
@@ -105,7 +105,7 @@ func TestCreateDuplicateGetsUniqueID(t *testing.T) {
func TestCreateInvalidXML(t *testing.T) {
s := newStore(t)
_, err := s.Create([]byte("not xml"))
_, err := s.Create([]byte("not xml"), "")
if err == nil {
t.Error("expected error for invalid XML")
}
@@ -145,13 +145,13 @@ func TestGetEmptyID(t *testing.T) {
func TestUpdate(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(sampleXML))
id, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
updated := `<interface id="test-if" name="Updated" version="2"><widget/></interface>`
if err := s.Update(id, []byte(updated)); err != nil {
if err := s.Update(id, []byte(updated), ""); err != nil {
t.Fatalf("Update: %v", err)
}
@@ -166,7 +166,7 @@ func TestUpdate(t *testing.T) {
func TestUpdateNotFound(t *testing.T) {
s := newStore(t)
err := s.Update("no-such-id", []byte(sampleXML))
err := s.Update("no-such-id", []byte(sampleXML), "")
if !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Update missing: want ErrNotFound, got %v", err)
}
@@ -178,7 +178,7 @@ func TestUpdateNotFound(t *testing.T) {
func TestDelete(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(sampleXML))
id, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
@@ -205,7 +205,7 @@ func TestDeleteNotFound(t *testing.T) {
func TestClone(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(sampleXML))
id, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("Create: %v", err)
}