484 lines
10 KiB
Go
484 lines
10 KiB
Go
package dataedit
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"strings"
|
|
"unsafe"
|
|
|
|
"aego/asset"
|
|
"aego/core/errs"
|
|
"aego/core/ids"
|
|
"aego/core/log"
|
|
"aego/data"
|
|
"aego/editor/core/context"
|
|
"aego/editor/core/document"
|
|
"aego/scene/mutate"
|
|
"aego/schema"
|
|
)
|
|
|
|
type change struct {
|
|
id ids.AssetID
|
|
field *schema.FieldDescriptor
|
|
before schema.Value
|
|
ptr unsafe.Pointer
|
|
inherited bool
|
|
}
|
|
|
|
type Service struct {
|
|
ctx *context.Editor
|
|
docs map[ids.AssetID]*Doc
|
|
tables map[ids.AssetID]*Table
|
|
inspected ids.AssetID
|
|
changes []change
|
|
types map[ids.AssetID]string
|
|
typesRev uint64
|
|
lib *data.Library
|
|
libSrc asset.DataSource
|
|
OnLive func()
|
|
}
|
|
|
|
func New(ctx *context.Editor) *Service {
|
|
return &Service{ctx: ctx, docs: map[ids.AssetID]*Doc{}, tables: map[ids.AssetID]*Table{}, types: map[ids.AssetID]string{}}
|
|
}
|
|
|
|
func (s *Service) reg() *schema.Registry {
|
|
return s.ctx.Types.Schema()
|
|
}
|
|
|
|
func (s *Service) Inspect(id ids.AssetID) {
|
|
s.inspected = id
|
|
}
|
|
|
|
func (s *Service) Clear() {
|
|
s.inspected = ids.AssetID{}
|
|
}
|
|
|
|
func (s *Service) Inspected() ids.AssetID {
|
|
if s.inspected.IsZero() || s.ctx.Pipeline == nil {
|
|
return ids.AssetID{}
|
|
}
|
|
if _, ok := s.ctx.Pipeline.PathOf(s.inspected); !ok {
|
|
s.inspected = ids.AssetID{}
|
|
}
|
|
return s.inspected
|
|
}
|
|
|
|
func (s *Service) InPlay() bool {
|
|
return s.ctx.Docs != nil && s.ctx.Docs.InPlay()
|
|
}
|
|
|
|
func (s *Service) source() (asset.DataSource, bool) {
|
|
if s.ctx.Loader == nil {
|
|
return nil, false
|
|
}
|
|
src, ok := any(s.ctx.Loader).(asset.DataSource)
|
|
return src, ok
|
|
}
|
|
|
|
func (s *Service) Library() (*data.Library, error) {
|
|
src, ok := s.source()
|
|
if !ok {
|
|
return nil, data.ErrNoSource
|
|
}
|
|
if s.lib == nil || s.libSrc != src {
|
|
s.lib, s.libSrc = data.NewLibrary(s.reg(), src), src
|
|
}
|
|
return s.lib, nil
|
|
}
|
|
|
|
func (s *Service) noPipeline() error {
|
|
return errs.New(errs.EditorDocument, "the asset pipeline is not available")
|
|
}
|
|
|
|
func (s *Service) file(id ids.AssetID) (string, []byte, error) {
|
|
pl := s.ctx.Pipeline
|
|
if pl == nil {
|
|
return "", nil, s.noPipeline()
|
|
}
|
|
rel, ok := pl.PathOf(id)
|
|
if !ok {
|
|
return "", nil, errs.New(errs.EditorDocument, "asset "+id.Short()+" is not in the project")
|
|
}
|
|
raw, err := os.ReadFile(pl.Abs(rel))
|
|
if err != nil {
|
|
return "", nil, errs.Wrap(err, errs.EditorDocument, "cannot read the data asset").With(log.F("path", rel))
|
|
}
|
|
return rel, raw, nil
|
|
}
|
|
|
|
func (s *Service) env() mutate.Env {
|
|
env := mutate.Env{Types: s.ctx.Types}
|
|
if pl := s.ctx.Pipeline; pl != nil {
|
|
env.Lookup = pl.Lookup
|
|
env.Assets = func(id ids.AssetID) bool {
|
|
_, ok := pl.PathOf(id)
|
|
return ok || asset.IsBuiltin(id)
|
|
}
|
|
}
|
|
return env
|
|
}
|
|
|
|
func (s *Service) Doc(id ids.AssetID) (*Doc, error) {
|
|
d := s.docs[id]
|
|
if d != nil && d.rev == s.ctx.AssetRev {
|
|
return d, nil
|
|
}
|
|
rel, raw, err := s.file(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if d != nil && d.Dirty() {
|
|
s.conflict(rel, raw, d.disk, &d.conflict)
|
|
d.Path, d.rev = rel, s.ctx.AssetRev
|
|
return d, d.load()
|
|
}
|
|
if d != nil && bytes.Equal(raw, d.disk) {
|
|
d.Path, d.rev = rel, s.ctx.AssetRev
|
|
return d, d.load()
|
|
}
|
|
lib, err := s.Library()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nd, err := NewDoc(s.env(), lib, id, rel, raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nd.disk, nd.rev = raw, s.ctx.AssetRev
|
|
s.docs[id] = nd
|
|
return nd, nil
|
|
}
|
|
|
|
func (s *Service) Table(id ids.AssetID) (*Table, error) {
|
|
t := s.tables[id]
|
|
if t != nil && t.rev == s.ctx.AssetRev {
|
|
return t, nil
|
|
}
|
|
rel, raw, err := s.file(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if t != nil && (t.Dirty() || bytes.Equal(raw, t.disk)) {
|
|
if t.Dirty() {
|
|
s.conflict(rel, raw, t.disk, &t.conflict)
|
|
}
|
|
t.Path, t.rev = rel, s.ctx.AssetRev
|
|
return t, nil
|
|
}
|
|
nt, err := NewTable(s.env(), id, rel, raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nt.disk, nt.rev = raw, s.ctx.AssetRev
|
|
s.tables[id] = nt
|
|
return nt, nil
|
|
}
|
|
|
|
func (s *Service) conflict(rel string, raw, disk []byte, seen *[]byte) {
|
|
if bytes.Equal(raw, disk) || bytes.Equal(raw, *seen) {
|
|
return
|
|
}
|
|
*seen = raw
|
|
s.ctx.Notify(context.LevelWarn, rel+" changed on disk while it has unsaved changes: Save keeps yours, Revert loads the disk version")
|
|
}
|
|
|
|
func (s *Service) RevertDoc(id ids.AssetID) {
|
|
delete(s.docs, id)
|
|
}
|
|
|
|
func (s *Service) RevertTable(id ids.AssetID) {
|
|
delete(s.tables, id)
|
|
}
|
|
|
|
func (s *Service) save(id ids.AssetID, rel string, b []byte, done func([]byte)) error {
|
|
pl := s.ctx.Pipeline
|
|
if pl == nil {
|
|
return s.noPipeline()
|
|
}
|
|
if err := document.WriteAtomic(pl.Abs(rel), b); err != nil {
|
|
return errs.Wrap(err, errs.EditorDocument, "cannot save the data asset").With(log.F("path", rel))
|
|
}
|
|
done(b)
|
|
batch, err := pl.Reimport(id)
|
|
s.ctx.ApplyBatch(batch)
|
|
if d := s.docs[id]; d != nil {
|
|
d.rev = s.ctx.AssetRev
|
|
}
|
|
if t := s.tables[id]; t != nil {
|
|
t.rev = s.ctx.AssetRev
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *Service) playLocked() error {
|
|
if s.InPlay() {
|
|
return errs.New(errs.EditorCommand, "data assets are saved after Play")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) SaveDoc(d *Doc) error {
|
|
if err := s.playLocked(); err != nil {
|
|
return err
|
|
}
|
|
return s.save(d.ID, d.Path, d.Bytes(), func(b []byte) {
|
|
d.MarkSaved()
|
|
d.disk = b
|
|
})
|
|
}
|
|
|
|
func (s *Service) SaveTable(t *Table) error {
|
|
if err := s.playLocked(); err != nil {
|
|
return err
|
|
}
|
|
return s.save(t.ID, t.Path, t.Bytes(), func(b []byte) {
|
|
t.MarkSaved()
|
|
t.disk = b
|
|
})
|
|
}
|
|
|
|
func csvPath(rel string) string {
|
|
return strings.TrimSuffix(rel, path.Ext(rel)) + ".csv"
|
|
}
|
|
|
|
func (s *Service) ExportCSV(t *Table) (string, error) {
|
|
if s.ctx.Pipeline == nil {
|
|
return "", s.noPipeline()
|
|
}
|
|
b, err := t.ExportCSV()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
rel := csvPath(t.Path)
|
|
if err := document.WriteAtomic(s.ctx.Pipeline.Abs(rel), b); err != nil {
|
|
return "", errs.Wrap(err, errs.EditorDocument, "cannot write "+rel)
|
|
}
|
|
return rel, nil
|
|
}
|
|
|
|
func (s *Service) ImportCSV(t *Table) (string, error) {
|
|
if s.ctx.Pipeline == nil {
|
|
return "", s.noPipeline()
|
|
}
|
|
rel := csvPath(t.Path)
|
|
b, err := os.ReadFile(s.ctx.Pipeline.Abs(rel))
|
|
if err != nil {
|
|
return rel, errs.Wrap(err, errs.EditorDocument, "cannot read "+rel)
|
|
}
|
|
return rel, t.ImportCSV(b)
|
|
}
|
|
|
|
func (s *Service) AssetLabel(id ids.AssetID) string {
|
|
if s.ctx.Pipeline != nil {
|
|
if e, ok := s.ctx.Pipeline.Entry(id); ok && !e.Missing {
|
|
return path.Base(e.Name)
|
|
}
|
|
}
|
|
return id.Short()
|
|
}
|
|
|
|
func (s *Service) TypeOf(id ids.AssetID) (string, bool) {
|
|
if s.typesRev != s.ctx.AssetRev {
|
|
clear(s.types)
|
|
s.typesRev = s.ctx.AssetRev
|
|
}
|
|
t, ok := s.types[id]
|
|
if !ok {
|
|
t = s.readType(id)
|
|
s.types[id] = t
|
|
}
|
|
return t, t != ""
|
|
}
|
|
|
|
func (s *Service) readType(id ids.AssetID) string {
|
|
src, ok := s.source()
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if raw, ok := src.Data(id); ok {
|
|
if d, err := data.ParseDoc(raw, id.String()); err == nil {
|
|
return d.Type
|
|
}
|
|
}
|
|
if raw, ok := src.Table(id); ok {
|
|
if t, err := data.ParseTable(raw, id.String()); err == nil {
|
|
return t.Type
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Service) LiveRecord(id ids.AssetID) (unsafe.Pointer, error) {
|
|
lib, err := s.Library()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rec, err := lib.Record(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rec.Ptr, nil
|
|
}
|
|
|
|
func (s *Service) LiveSet(id ids.AssetID, fd *schema.FieldDescriptor, v schema.Value) error {
|
|
if !fd.Flags.Has(schema.FlagLive) {
|
|
return errs.New(errs.EditorCommand, fd.Name+" is not a live field, change it after Play")
|
|
}
|
|
lib, err := s.Library()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rec, err := lib.Record(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
heirs := s.heirs(lib, rec, fd, nil, 0)
|
|
if err := s.patch(rec, fd, v, false); err != nil {
|
|
return err
|
|
}
|
|
for _, h := range heirs {
|
|
if err := s.patch(h, fd, v, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if s.OnLive != nil {
|
|
s.OnLive()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) heirs(lib *data.Library, rec *data.Record, fd *schema.FieldDescriptor, out []*data.Record, depth int) []*data.Record {
|
|
if depth >= data.MaxDepth || s.ctx.Pipeline == nil {
|
|
return out
|
|
}
|
|
for _, dep := range s.ctx.Pipeline.Dependents(rec.ID) {
|
|
child, err := lib.Record(dep)
|
|
if err != nil || child.Base != rec.ID || child.Type != rec.Type || s.overrides(dep, fd.Name) {
|
|
continue
|
|
}
|
|
out = append(out, child)
|
|
out = s.heirs(lib, child, fd, out, depth+1)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Service) overrides(id ids.AssetID, name string) bool {
|
|
src, ok := s.source()
|
|
if !ok {
|
|
return false
|
|
}
|
|
raw, ok := src.Data(id)
|
|
if !ok {
|
|
return false
|
|
}
|
|
d, err := data.ParseDoc(raw, id.String())
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, f := range d.Fields {
|
|
if f.Name == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Service) touched(id ids.AssetID, fd *schema.FieldDescriptor) bool {
|
|
for _, c := range s.changes {
|
|
if c.id == id && c.field == fd {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Service) patch(rec *data.Record, fd *schema.FieldDescriptor, v schema.Value, inherited bool) error {
|
|
if !s.touched(rec.ID, fd) {
|
|
var before schema.Value
|
|
fd.Get(rec.Ptr, &before)
|
|
s.changes = append(s.changes, change{id: rec.ID, field: fd, before: before, ptr: rec.Ptr, inherited: inherited})
|
|
}
|
|
return fd.Set(rec.Ptr, v)
|
|
}
|
|
|
|
func (s *Service) LiveChanges() int {
|
|
return len(s.changes)
|
|
}
|
|
|
|
func (s *Service) PlayStopped() {
|
|
if len(s.changes) == 0 {
|
|
return
|
|
}
|
|
list := s.changes
|
|
s.changes = nil
|
|
own := map[ids.AssetID]bool{}
|
|
fields := 0
|
|
for _, c := range list {
|
|
if !c.inherited {
|
|
own[c.id] = true
|
|
fields++
|
|
}
|
|
}
|
|
s.ctx.Ask(context.Question{
|
|
Title: "Live data changes",
|
|
Message: fmt.Sprintf("%d field(s) in %d data asset(s) were tuned during Play. Keep them in the assets?", fields, len(own)),
|
|
Buttons: []string{"Keep", "Discard"},
|
|
Then: func(choice int) {
|
|
if choice == 0 {
|
|
s.keep(list)
|
|
return
|
|
}
|
|
s.discard(list)
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Service) discard(list []change) {
|
|
for i := len(list) - 1; i >= 0; i-- {
|
|
c := list[i]
|
|
c.field.Set(c.ptr, c.before)
|
|
}
|
|
}
|
|
|
|
type kept struct {
|
|
fd *schema.FieldDescriptor
|
|
v schema.Value
|
|
}
|
|
|
|
func (s *Service) keep(list []change) {
|
|
var order []ids.AssetID
|
|
byID := map[ids.AssetID][]kept{}
|
|
for _, c := range list {
|
|
if c.inherited {
|
|
continue
|
|
}
|
|
var v schema.Value
|
|
c.field.Get(c.ptr, &v)
|
|
if _, ok := byID[c.id]; !ok {
|
|
order = append(order, c.id)
|
|
}
|
|
byID[c.id] = append(byID[c.id], kept{fd: c.field, v: v})
|
|
}
|
|
s.discard(list)
|
|
for _, id := range order {
|
|
d, err := s.Doc(id)
|
|
if err != nil {
|
|
s.ctx.Notify(context.LevelError, err.Error())
|
|
continue
|
|
}
|
|
for i, k := range byID[id] {
|
|
if err := d.Set(k.fd, k.v, i == 0); err != nil {
|
|
s.ctx.Notify(context.LevelError, err.Error())
|
|
}
|
|
}
|
|
if err := s.SaveDoc(d); err != nil {
|
|
s.ctx.Notify(context.LevelError, err.Error())
|
|
continue
|
|
}
|
|
s.ctx.Notify(context.LevelInfo, "Kept the live changes in "+d.Path)
|
|
}
|
|
}
|